From 76aff39a40efc12e1f77a8e6f6ffb4ac56fb50f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 04:36:58 +0000 Subject: [PATCH 01/24] feat: document each rule's rationale and references Rules carried only a one-line description of what they check, never why or where the behavior is defined. Add LongDescription and References to linter.Rule, fill both in for every built-in rule from the Dev Container specification and the relevant runtime documentation, and surface them: - SARIF rule descriptors gain fullDescription and help (text and markdown), so the rationale and links show on a Code Scanning alert. - New -explain flag prints a single rule's documentation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- CONTRIBUTING.md | 23 ++++-- README.md | 12 +++ cmd/decolint/format.go | 8 +- cmd/decolint/format_test.go | 35 +++++++++ cmd/decolint/main.go | 61 ++++++++++++--- cmd/decolint/main_test.go | 56 ++++++++++++++ cmd/decolint/opts.go | 4 + cmd/decolint/opts_test.go | 27 +++++++ format/sarif.go | 59 ++++++++++++-- format/sarif_test.go | 77 ++++++++++++++++++- linter/rule.go | 7 ++ rules/conflicting_container_def.go | 16 +++- rules/doc_test.go | 65 ++++++++++++++++ .../feature_install_script_not_executable.go | 14 +++- rules/id_dir_mismatch.go | 16 +++- rules/invalid_semver.go | 15 +++- rules/missing_build_dockerfile.go | 15 +++- rules/missing_compose_service.go | 15 +++- rules/missing_container_def.go | 15 +++- rules/missing_feature_install_script.go | 15 +++- rules/missing_required_props.go | 15 +++- rules/missing_workspace_mount_folder.go | 16 +++- rules/no_app_port.go | 16 +++- rules/no_bind_mount.go | 18 +++-- rules/no_cap_add_all.go | 16 +++- rules/no_docker_socket_mount.go | 17 +++- rules/no_host_port_format.go | 18 +++-- rules/no_image_latest.go | 15 +++- rules/no_privileged_container.go | 17 +++- rules/no_seccomp_override.go | 17 +++- rules/no_seccomp_unconfined.go | 16 +++- rules/pin_extension_version.go | 18 +++-- rules/pin_feature_version.go | 16 +++- rules/pin_image_digest.go | 16 +++- rules/require_cap_drop_all.go | 16 +++- rules/require_no_new_privileges.go | 16 +++- rules/require_non_root.go | 16 +++- rules/undefined_template_option.go | 16 +++- rules/unused_template_option.go | 15 +++- 39 files changed, 725 insertions(+), 140 deletions(-) create mode 100644 rules/doc_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11c1c31..0f472bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,13 @@ and calls `Check` for every value matching one of the paths; a `*` segment matches any object member name or array index, and the empty string matches the document root. +Besides the one-line `Description` of what it checks, every rule +carries a `LongDescription` explaining why the configuration it +reports is a problem, and at least one `References` URL pointing at +the specification, documentation, or implementation that justifies it. +Both are shown by `decolint -explain ` and in the SARIF +output, so write them for the user who just hit the finding. + A rule's default severity is not set individually; it comes entirely from its category (see `categoryDefaultSeverities` in [`rules.go`](rules/rules.go)) — only `CategoryCorrectness` runs by @@ -46,13 +53,15 @@ package rules import "github.com/bare-devcontainer/decolint/linter" var MyRule = &linter.Rule{ - ID: "my-rule", - Description: "...", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: nil, // applies to every platform - Paths: []string{"/mounts/*"}, - Check: checkMyRule, + ID: "my-rule", + Description: "...", + LongDescription: "...", + References: []string{"https://containers.dev/implementors/json_reference/"}, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: nil, // applies to every platform + Paths: []string{"/mounts/*"}, + Check: checkMyRule, } func checkMyRule(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/README.md b/README.md index 195016e..53739d1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ The remaining flags perform a one-off action and exit; run | `-config ` | use the config file at `` instead of [auto-discovery](#config-file) | | `-init` | write a new `.decolint.jsonc` listing every rule at its default severity | | `-rules` | print the built-in rules as a Markdown table | +| `-explain ` | print what one rule checks, why, and where it is documented | | `-version` | print version information | | `-help` | print usage | @@ -282,6 +283,17 @@ can also optionally target specific platforms (see [Target platforms](#target-platforms)); a rule with no target platform applies to all platforms. +Each rule also explains why it exists and links to the specification +or documentation it is based on. To read that for a single rule: + +```console +decolint -explain no-privileged-container +``` + +The [SARIF output](#output-formats) carries the same explanation and +links for every rule it reports, so they show up on the alert in +GitHub Code Scanning. + ### Rule categories Only `correctness` runs by default; the rest are `off` until enabled: diff --git a/cmd/decolint/format.go b/cmd/decolint/format.go index 36a9e68..bc0abab 100644 --- a/cmd/decolint/format.go +++ b/cmd/decolint/format.go @@ -40,9 +40,11 @@ func sarifRules() []format.SARIFRule { out := make([]format.SARIFRule, len(regs)) for i, reg := range regs { out[i] = format.SARIFRule{ - ID: reg.Rule.ID, - Description: reg.Rule.Description, - Category: reg.Rule.Category.String(), + ID: reg.Rule.ID, + Description: reg.Rule.Description, + LongDescription: reg.Rule.LongDescription, + References: reg.Rule.References, + Category: reg.Rule.Category.String(), } } return out diff --git a/cmd/decolint/format_test.go b/cmd/decolint/format_test.go index 05e6789..c8b4d1d 100644 --- a/cmd/decolint/format_test.go +++ b/cmd/decolint/format_test.go @@ -4,9 +4,44 @@ import ( "testing" "github.com/bare-devcontainer/decolint/format" + "github.com/bare-devcontainer/decolint/rules" "github.com/google/go-cmp/cmp" ) +// TestSARIFRules checks that the adapter carries every documented field of a built-in rule into the +// SARIF catalog, so an alert can be traced back to what the rule checks and why. +func TestSARIFRules(t *testing.T) { + t.Parallel() + + var reg rules.Registration + for _, r := range rules.Builtin() { + if r.Rule.ID == "no-image-latest" { + reg = r + } + } + if reg.Rule == nil { + t.Fatal("built-in rule no-image-latest not found") + } + + var got format.SARIFRule + for _, r := range sarifRules() { + if r.ID == reg.Rule.ID { + got = r + } + } + + want := format.SARIFRule{ + ID: reg.Rule.ID, + Description: reg.Rule.Description, + LongDescription: reg.Rule.LongDescription, + References: reg.Rule.References, + Category: reg.Rule.Category.String(), + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("sarifRules() entry mismatch (-want +got):\n%s", diff) + } +} + func TestParseFormat(t *testing.T) { t.Parallel() diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index 4b352ea..4cc7788 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -12,6 +12,7 @@ import ( "os/signal" "path" "path/filepath" + "slices" "strings" "syscall" @@ -79,6 +80,14 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return exitCodeSuccess } + if opts.Explain != "" { + if err := explainRule(stdout, opts.Explain); err != nil { + _, _ = fmt.Fprintln(stderr, progName+":", err) + return exitCodeError + } + return exitCodeSuccess + } + cfg, err := loadConfig(opts.ConfigPath) if err != nil { _, _ = fmt.Fprintln(stderr, progName+":", err) @@ -128,18 +137,10 @@ func listRules(output io.Writer, cfg Config) error { overrides := rules.Overrides{Categories: cfg.Categories, Rules: cfg.Rules} rows := [][]string{rulesTableHeader} for _, reg := range rules.Builtin() { - platforms := "(all)" - if len(reg.Rule.Platforms) > 0 { - names := make([]string, len(reg.Rule.Platforms)) - for i, p := range reg.Rule.Platforms { - names[i] = p.String() - } - platforms = strings.Join(names, ",") - } rows = append(rows, []string{ reg.Rule.ID, reg.Rule.Category.String(), - platforms, + platformNames(reg.Rule.Platforms, ","), severityEmoji[overrides.SeverityFor(reg)], }) } @@ -163,6 +164,48 @@ func listRules(output io.Writer, cfg Config) error { return nil } +// platformNames renders the platforms a rule targets, separated by sep. A rule that targets none +// applies to every platform, which is shown as "(all)". +func platformNames(platforms []linter.Platform, sep string) string { + if len(platforms) == 0 { + return "(all)" + } + names := make([]string, len(platforms)) + for i, p := range platforms { + names[i] = p.String() + } + return strings.Join(names, sep) +} + +// explainRule writes everything decolint documents about the rule with the given ID to output: what +// it checks, why, and the references that justify it. It returns an error if no built-in rule has +// that ID. The rule's severity is left to [listRules], which reports it for every rule at once. +func explainRule(output io.Writer, id string) error { + builtin := rules.Builtin() + i := slices.IndexFunc(builtin, func(reg rules.Registration) bool { return reg.Rule.ID == id }) + if i < 0 { + return fmt.Errorf("unknown rule ID %q; run with -rules to list the built-in rules", id) + } + rule := builtin[i].Rule + + sections := []string{ + fmt.Sprintf("%s (%s)\nPlatform: %s", rule.ID, rule.Category, platformNames(rule.Platforms, ", ")), + rule.Description, + rule.LongDescription, + } + if len(rule.References) > 0 { + refs := "References:" + for _, ref := range rule.References { + refs += "\n " + ref + } + sections = append(sections, refs) + } + if _, err := fmt.Fprintln(output, strings.Join(sections, "\n\n")); err != nil { + return fmt.Errorf("write rule documentation: %w", err) + } + return nil +} + // columnWidths returns, for each column in rows, the display width of its widest cell, so every // row can be padded to a common set of column widths. func columnWidths(rows [][]string) []int { diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index 7b74891..d2a9647 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -434,6 +434,50 @@ func TestRun_Flags(t *testing.T) { } }) + t.Run("-explain", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + exitCode := run(t.Context(), []string{"-explain=no-bind-mount"}, &stdout, &stderr) + if exitCode != 0 { + t.Errorf("exit code = %d, want 0", exitCode) + } + if stderr.String() != "" { + t.Errorf("stderr = %q, want empty", stderr.String()) + } + out := stdout.String() + rule := builtinRule(t, "no-bind-mount") + for _, want := range []string{ + rule.ID, + rule.Category.String(), + // The rule targets Codespaces, so the platform it is scoped to is named rather than "(all)". + "codespaces", + rule.Description, + rule.LongDescription, + rule.References[0], + } { + if !strings.Contains(out, want) { + t.Errorf("stdout = %q, want it to contain %q", out, want) + } + } + }) + + t.Run("-explain unknown rule", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + exitCode := run(t.Context(), []string{"-explain=no-such-rule"}, &stdout, &stderr) + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } + if stdout.String() != "" { + t.Errorf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "no-such-rule") { + t.Errorf("stderr = %q, want it to mention the unknown rule ID", stderr.String()) + } + }) + t.Run("-help", func(t *testing.T) { t.Parallel() @@ -1831,6 +1875,18 @@ func mdTableRow(t *testing.T, out, key string) []string { return nil } +// builtinRule returns the built-in rule with the given ID, failing the test if there is none. +func builtinRule(t *testing.T, id string) *linter.Rule { + t.Helper() + for _, reg := range rules.Builtin() { + if reg.Rule.ID == id { + return reg.Rule + } + } + t.Fatalf("no built-in rule with ID %q", id) + return nil +} + // writeDevcontainer writes a minimal devcontainer.json, with the given body as its content, at the // spec-defined location under a fresh temp directory, and returns that directory's path. func writeDevcontainer(t *testing.T, body string) string { diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index 4e71840..5d81a61 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -44,6 +44,9 @@ type Options struct { Version bool // ListRules, when set, causes the program to print the built-in rules and exit. ListRules bool + // Explain, when non-empty, is the ID of the rule to describe in full; the program prints its + // documentation and exits. + Explain string // Init, when set, causes the program to write a new .decolint.jsonc config file listing every // rule at its default severity, then exit. Init bool @@ -64,6 +67,7 @@ func parseOptions(args []string, output io.Writer) (Options, error) { fs.BoolVar(&opts.Merge, "merge", false, "lint the merged (effective) configuration, including referenced Features and base image metadata; overrides the config file's \"merge\" member") fs.BoolVar(&opts.Version, "version", false, "print version information and exit") fs.BoolVar(&opts.ListRules, "rules", false, "print the built-in rules as a Markdown table (category, target platforms, current severity), then exit") + fs.StringVar(&opts.Explain, "explain", "", "print what the rule with this ID checks, why, and where it is documented, then exit") fs.BoolVar(&opts.Init, "init", false, "write a new .decolint.jsonc config file listing every rule at its default severity, then exit") fs.Usage = func() { _ = usage(fs) } if err := fs.Parse(args); err != nil { diff --git a/cmd/decolint/opts_test.go b/cmd/decolint/opts_test.go index d9c48d8..e20593f 100644 --- a/cmd/decolint/opts_test.go +++ b/cmd/decolint/opts_test.go @@ -196,6 +196,33 @@ func TestParseOptions_DenyWarningsSet(t *testing.T) { } } +func TestParseOptions_Explain(t *testing.T) { + t.Parallel() + + // The rule ID is captured verbatim; explainRule is what rejects one that names no built-in rule. + tests := []struct { + name string + args []string + want string + }{ + {"no flag", nil, ""}, + {"rule id", []string{"-explain=no-image-latest"}, "no-image-latest"}, + {"unknown rule id", []string{"-explain=bogus"}, "bogus"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + opts, err := parseOptions(tt.args, io.Discard) + if err != nil { + t.Fatalf("parseOptions(%v) error = %v", tt.args, err) + } + if opts.Explain != tt.want { + t.Errorf("Explain = %q, want %q", opts.Explain, tt.want) + } + }) + } +} + func TestParseOptions_Config(t *testing.T) { t.Parallel() diff --git a/format/sarif.go b/format/sarif.go index 8bc5045..7d75b9a 100644 --- a/format/sarif.go +++ b/format/sarif.go @@ -17,9 +17,11 @@ import ( // SARIFRule describes one rule for the SARIF rule catalog. It mirrors the [linter.Rule] fields the // SARIF output needs, so this package does not depend on the rules package. type SARIFRule struct { - ID string - Description string - Category string + ID string + Description string + LongDescription string + References []string + Category string } // SARIFFormat prints a SARIF 2.1.0 log, suitable for upload to GitHub Code Scanning. @@ -62,9 +64,11 @@ type sarifDriver struct { } type sarifRuleDescriptor struct { - ID string `json:"id"` - ShortDescription sarifMessage `json:"shortDescription,omitzero"` - Properties sarifProperties `json:"properties,omitzero"` + ID string `json:"id"` + ShortDescription sarifMessage `json:"shortDescription,omitzero"` + FullDescription sarifMessage `json:"fullDescription,omitzero"` + Help sarifMultiformatMessage `json:"help,omitzero"` + Properties sarifProperties `json:"properties,omitzero"` } type sarifProperties struct { @@ -75,6 +79,14 @@ type sarifMessage struct { Text string `json:"text"` } +// sarifMultiformatMessage is SARIF's multiformatMessageString: a plain-text rendering plus an +// optional Markdown one, which viewers prefer when they can render it. Text is required whenever +// Markdown is present. +type sarifMultiformatMessage struct { + Text string `json:"text"` + Markdown string `json:"markdown,omitzero"` +} + type sarifResult struct { RuleID string `json:"ruleId"` RuleIndex int `json:"ruleIndex"` @@ -126,6 +138,8 @@ func (f SARIFFormat) WriteIssues(w io.Writer, issues []linter.Issue) error { desc := sarifRuleDescriptor{ID: id} if r, ok := catalog[id]; ok { desc.ShortDescription = sarifMessage{Text: r.Description} + desc.FullDescription = sarifMessage{Text: r.LongDescription} + desc.Help = sarifHelpFor(r) desc.Properties = sarifProperties{Tags: []string{r.Category}} } descriptors = append(descriptors, desc) @@ -178,6 +192,39 @@ func (f SARIFFormat) WriteIssues(w io.Writer, issues []linter.Issue) error { return nil } +// sarifHelpFor returns the rule's rationale and reference links as SARIF help, the text a viewer +// shows when a reader asks why an alert was raised. It is the zero value, and so omitted, for a rule +// that documents neither. +func sarifHelpFor(r SARIFRule) sarifMultiformatMessage { + if len(r.References) == 0 { + // Prose alone renders the same either way, so there is no Markdown rendering to add. + return sarifMultiformatMessage{Text: r.LongDescription} + } + var plain, md strings.Builder + plain.WriteString("References:") + md.WriteString("**References**\n") + for _, ref := range r.References { + plain.WriteString("\n- " + ref) + // Angle brackets make the URL a link even where it contains Markdown punctuation. + md.WriteString("\n- <" + ref + ">") + } + return sarifMultiformatMessage{ + Text: joinParagraphs(r.LongDescription, plain.String()), + Markdown: joinParagraphs(r.LongDescription, md.String()), + } +} + +// joinParagraphs joins the non-empty sections with a blank line between them. +func joinParagraphs(sections ...string) string { + kept := make([]string, 0, len(sections)) + for _, s := range sections { + if s != "" { + kept = append(kept, s) + } + } + return strings.Join(kept, "\n\n") +} + // artifactURIFor returns the SARIF location of the file at path, which must be a URI rather than a // filesystem path. A relative path stays relative, for the consumer to resolve against the root of // the analyzed project; an absolute one, which decolint reports for a file outside the directory it diff --git a/format/sarif_test.go b/format/sarif_test.go index 3276a18..b8b9477 100644 --- a/format/sarif_test.go +++ b/format/sarif_test.go @@ -14,9 +14,11 @@ func testSARIFFormat() SARIFFormat { Version: "1.2.3", Rules: []SARIFRule{ { - ID: "no-image-latest", - Description: "images should be pinned to a specific version", - Category: "reproducibility", + ID: "no-image-latest", + Description: "images should be pinned to a specific version", + LongDescription: "An unpinned image can change between builds.", + References: []string{"https://containers.dev/implementors/json_reference/"}, + Category: "reproducibility", }, }, } @@ -32,7 +34,11 @@ func TestSARIFWriteIssues(t *testing.T) { want := `{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","version":"2.1.0","runs":[` + `{"tool":{"driver":{"name":"decolint","version":"1.2.3","informationUri":"https://github.com/bare-devcontainer/decolint","rules":[` + - `{"id":"no-image-latest","shortDescription":{"text":"images should be pinned to a specific version"},"properties":{"tags":["reproducibility"]}},` + + `{"id":"no-image-latest","shortDescription":{"text":"images should be pinned to a specific version"},` + + `"fullDescription":{"text":"An unpinned image can change between builds."},` + + `"help":{"text":"An unpinned image can change between builds.\n\nReferences:\n- https://containers.dev/implementors/json_reference/",` + + `"markdown":"An unpinned image can change between builds.\n\n**References**\n\n- "},` + + `"properties":{"tags":["reproducibility"]}},` + `{"id":"some-error-rule"}]}},"results":[` + `{"ruleId":"no-image-latest","ruleIndex":0,"level":"warning","message":{"text":"image \"ubuntu:latest\" uses the \"latest\" tag; pin a specific version"},` + `"locations":[{"physicalLocation":{"artifactLocation":{"uri":".devcontainer/devcontainer.json"},"region":{"startLine":4,"startColumn":12}}}]},` + @@ -124,6 +130,69 @@ func TestSARIFWriteIssues_Empty(t *testing.T) { } } +// TestSARIFWriteIssues_RuleHelp checks how a rule's rationale and references become the descriptor's +// fullDescription and help, including that a rule documenting neither carries neither field. +func TestSARIFWriteIssues_RuleHelp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rule SARIFRule + want []string + wantOmitted []string + }{ + { + name: "rationale and references", + rule: SARIFRule{ + ID: "some-error-rule", + LongDescription: "Why it matters.", + References: []string{"https://example.invalid/a", "https://example.invalid/b"}, + }, + want: []string{ + `"fullDescription":{"text":"Why it matters."}`, + `"help":{"text":"Why it matters.\n\nReferences:\n- https://example.invalid/a\n- https://example.invalid/b",` + + `"markdown":"Why it matters.\n\n**References**\n\n- \n- "}`, + }, + }, + { + name: "rationale only", + rule: SARIFRule{ID: "some-error-rule", LongDescription: "Why it matters."}, + want: []string{ + `"fullDescription":{"text":"Why it matters."}`, + `"help":{"text":"Why it matters."}`, + }, + wantOmitted: []string{`"markdown"`}, + }, + { + name: "neither", + rule: SARIFRule{ID: "some-error-rule", Description: "short only"}, + want: []string{`"shortDescription":{"text":"short only"}`}, + wantOmitted: []string{`"fullDescription"`, `"help"`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := SARIFFormat{Version: "1.2.3", Rules: []SARIFRule{tt.rule}} + var sb strings.Builder + if err := f.WriteIssues(&sb, testIssues()[1:]); err != nil { + t.Fatalf("WriteIssues: %v", err) + } + for _, want := range tt.want { + if !strings.Contains(sb.String(), want) { + t.Errorf("WriteIssues sarif = %q, want it to contain %q", sb.String(), want) + } + } + for _, omitted := range tt.wantOmitted { + if strings.Contains(sb.String(), omitted) { + t.Errorf("WriteIssues sarif = %q, want it to omit %q", sb.String(), omitted) + } + } + }) + } +} + func TestSARIFWriteIssues_WriteError(t *testing.T) { t.Parallel() diff --git a/linter/rule.go b/linter/rule.go index a076d1f..b1ab908 100644 --- a/linter/rule.go +++ b/linter/rule.go @@ -262,6 +262,13 @@ type Rule struct { ID string // Description is a short human-readable description of what the rule checks. Description string + // LongDescription explains why the rule exists: what goes wrong in the configuration it + // reports, and what to do instead. It is prose, wrapped as written, and is shown alongside + // References wherever a rule is documented rather than merely named. + LongDescription string + // References are URLs to the specification, documentation, or implementation that justify the + // rule, most authoritative first. + References []string // Category is the [Category] this rule reports; every rule must declare exactly one. Category Category // FileTypes are the kinds of configuration files this rule applies to. diff --git a/rules/conflicting_container_def.go b/rules/conflicting_container_def.go index b8740b6..6f9c4f0 100644 --- a/rules/conflicting_container_def.go +++ b/rules/conflicting_container_def.go @@ -11,10 +11,18 @@ import ( var ConflictingContainerDef = &linter.Rule{ ID: "conflicting-container-def", Description: `disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkConflictingContainerDef, + LongDescription: `The specification defines three mutually exclusive ways to create the container: from an image, from a +Dockerfile, or from a Docker Compose project. Which one wins when several are set is unspecified, so the +container that gets built depends on the tool rather than on the configuration. Keep the variant the +project actually uses and remove the others.`, + References: []string{ + "https://containers.dev/implementors/spec/#orchestration-options", + "https://containers.dev/implementors/json_reference/#scenario-specific-properties", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkConflictingContainerDef, } func checkConflictingContainerDef(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/doc_test.go b/rules/doc_test.go new file mode 100644 index 0000000..3acb427 --- /dev/null +++ b/rules/doc_test.go @@ -0,0 +1,65 @@ +package rules_test + +import ( + "net/url" + "slices" + "strings" + "testing" + + "github.com/bare-devcontainer/decolint/rules" +) + +// Every built-in rule documents itself: a short description of what it checks, a rationale, and the +// references that justify it. These tests are what keeps a new rule from landing undocumented. + +func TestBuiltin_Descriptions(t *testing.T) { + t.Parallel() + + for _, reg := range rules.Builtin() { + if strings.TrimSpace(reg.Rule.Description) == "" { + t.Errorf("rule %s has no Description", reg.Rule.ID) + } + if strings.TrimSpace(reg.Rule.LongDescription) == "" { + t.Errorf("rule %s has no LongDescription", reg.Rule.ID) + } + if got := reg.Rule.LongDescription; got != strings.TrimSpace(got) { + t.Errorf("rule %s LongDescription has leading or trailing whitespace", reg.Rule.ID) + } + } +} + +func TestBuiltin_References(t *testing.T) { + t.Parallel() + + for _, reg := range rules.Builtin() { + if len(reg.Rule.References) == 0 { + t.Errorf("rule %s has no References", reg.Rule.ID) + } + for _, ref := range reg.Rule.References { + // A reference is rendered as a link wherever it is shown, so it has to be a URL a reader + // can follow on its own, not a bare path or a prose citation. + u, err := url.Parse(ref) + if err != nil { + t.Errorf("rule %s reference %q does not parse: %v", reg.Rule.ID, ref, err) + continue + } + if u.Scheme != "https" || u.Host == "" { + t.Errorf("rule %s reference %q is not an absolute https URL", reg.Rule.ID, ref) + } + } + if i := duplicateIndex(reg.Rule.References); i >= 0 { + t.Errorf("rule %s lists reference %q twice", reg.Rule.ID, reg.Rule.References[i]) + } + } +} + +// duplicateIndex returns the index of the first element of refs that appears earlier in it, or -1 +// if every element is unique. +func duplicateIndex(refs []string) int { + for i, ref := range refs { + if slices.Contains(refs[:i], ref) { + return i + } + } + return -1 +} diff --git a/rules/feature_install_script_not_executable.go b/rules/feature_install_script_not_executable.go index 9ba8c4b..69ac7fd 100644 --- a/rules/feature_install_script_not_executable.go +++ b/rules/feature_install_script_not_executable.go @@ -14,10 +14,16 @@ import ( var FeatureInstallScriptNotExecutable = &linter.Rule{ ID: "feature-install-script-not-executable", Description: "disallow a Feature's `install.sh` that lacks executable permission bits", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature}, - Paths: []string{""}, - Check: checkFeatureInstallScriptNotExecutable, + LongDescription: `The specification has the installing tool invoke "install.sh" directly rather than through a shell, so +that the script's own shebang selects the interpreter. That requires the execute bit: without it the +Feature fails to install when a container is built. Run "chmod +x install.sh" and commit the mode change.`, + References: []string{ + "https://containers.dev/implementors/features/#invoking-installsh", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Paths: []string{""}, + Check: checkFeatureInstallScriptNotExecutable, } func checkFeatureInstallScriptNotExecutable(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/id_dir_mismatch.go b/rules/id_dir_mismatch.go index 9256d12..9d3c398 100644 --- a/rules/id_dir_mismatch.go +++ b/rules/id_dir_mismatch.go @@ -12,10 +12,18 @@ import ( var IDDirMismatch = &linter.Rule{ ID: "id-dir-mismatch", Description: `disallow a Feature's or Template's "id" that does not match the name of its containing directory`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{"/id"}, - Check: checkIDDirMismatch, + LongDescription: `Both specifications require the "id" to match the name of the directory holding the metadata file, since +that directory name is what packaging and distribution address the artifact by. When the two disagree the +published reference does not resolve to what the directory contains; rename the directory or the "id" so +they agree.`, + References: []string{ + "https://containers.dev/implementors/features/#devcontainer-featurejson-properties", + "https://containers.dev/implementors/templates/#devcontainer-templatejson-properties", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{"/id"}, + Check: checkIDDirMismatch, } func checkIDDirMismatch(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/invalid_semver.go b/rules/invalid_semver.go index 424cd6e..6fbf3c9 100644 --- a/rules/invalid_semver.go +++ b/rules/invalid_semver.go @@ -13,10 +13,17 @@ import ( var InvalidSemver = &linter.Rule{ ID: "invalid-semver", Description: `disallow a Feature's or Template's "version" that is not a valid semantic version`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{"/version"}, - Check: checkInvalidSemver, + LongDescription: `Publishing a Feature or Template pushes it under tags derived from the "version" components: the full +version, "major.minor", and "major", so consumers can pin as loosely or as tightly as they want. A value +that is not valid semver has no such components, leaving nothing to derive those tags from.`, + References: []string{ + "https://containers.dev/implementors/features-distribution/#versioning", + "https://semver.org/", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{"/version"}, + Check: checkInvalidSemver, } // semverPattern is the official semantic version regular expression published at diff --git a/rules/missing_build_dockerfile.go b/rules/missing_build_dockerfile.go index 452879e..095b994 100644 --- a/rules/missing_build_dockerfile.go +++ b/rules/missing_build_dockerfile.go @@ -10,10 +10,17 @@ import ( var MissingBuildDockerfile = &linter.Rule{ ID: "missing-build-dockerfile", Description: `disallow a devcontainer.json "build" object that is missing "dockerfile"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/build"}, - Check: checkMissingBuildDockerfile, + LongDescription: `"build.dockerfile" is the only required member of "build": it locates, relative to the devcontainer.json, +the Dockerfile the image is built from. The other members ("context", "args", "target", ...) only shape a +build that "dockerfile" defines, so without it there is nothing to build.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", + "https://containers.dev/implementors/spec/#dockerfile-based", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/build"}, + Check: checkMissingBuildDockerfile, } func checkMissingBuildDockerfile(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_compose_service.go b/rules/missing_compose_service.go index 6bad04f..b2cde9e 100644 --- a/rules/missing_compose_service.go +++ b/rules/missing_compose_service.go @@ -10,10 +10,17 @@ import ( var MissingComposeService = &linter.Rule{ ID: "missing-compose-service", Description: `disallow a devcontainer.json that sets "dockerComposeFile" without "service"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingComposeService, + LongDescription: `A Compose project usually defines several services, so naming the Compose file does not say which +container the tooling should attach to. The specification requires "service" to name that main container: +it is the one lifecycle scripts run in and the one editors connect to.`, + References: []string{ + "https://containers.dev/implementors/spec/#docker-compose-based", + "https://containers.dev/implementors/json_reference/#docker-compose-specific-properties", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkMissingComposeService, } func checkMissingComposeService(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_container_def.go b/rules/missing_container_def.go index 2f432e8..3d25ea0 100644 --- a/rules/missing_container_def.go +++ b/rules/missing_container_def.go @@ -10,10 +10,17 @@ import ( var MissingContainerDef = &linter.Rule{ ID: "missing-container-def", Description: `disallow a devcontainer.json that defines none of "image", "build", or "dockerComposeFile"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingContainerDef, + LongDescription: `Every dev container is created from exactly one of "image", "build", or "dockerComposeFile", and each of +the three is required in its own scenario. A configuration that sets none of them describes no container +at all, so no tool can create one from it.`, + References: []string{ + "https://containers.dev/implementors/spec/#orchestration-options", + "https://containers.dev/implementors/json_reference/#scenario-specific-properties", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkMissingContainerDef, } func checkMissingContainerDef(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_feature_install_script.go b/rules/missing_feature_install_script.go index 0b16cd3..e273a05 100644 --- a/rules/missing_feature_install_script.go +++ b/rules/missing_feature_install_script.go @@ -16,10 +16,17 @@ const installScriptName = "install.sh" var MissingFeatureInstallScript = &linter.Rule{ ID: "missing-feature-install-script", Description: "disallow a Feature directory without the required `install.sh` install script", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature}, - Paths: []string{""}, - Check: checkMissingFeatureInstallScript, + LongDescription: `A Feature is distributed as its metadata file plus the "install.sh" the tooling runs inside the container, +which is where the Feature does all of its work. A directory without one publishes a Feature that +installs nothing, and the omission only surfaces when someone builds a container with it.`, + References: []string{ + "https://containers.dev/implementors/features/#folder-structure", + "https://containers.dev/implementors/features/#invoking-installsh", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Paths: []string{""}, + Check: checkMissingFeatureInstallScript, } func checkMissingFeatureInstallScript(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_required_props.go b/rules/missing_required_props.go index eeebe46..4f2604e 100644 --- a/rules/missing_required_props.go +++ b/rules/missing_required_props.go @@ -12,10 +12,17 @@ import ( var MissingRequiredProps = &linter.Rule{ ID: "missing-required-props", Description: `disallow a Feature's or Template's metadata that is missing a required property ("id", "version", or "name")`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{""}, - Check: checkMissingRequiredProps, + LongDescription: `"id", "version", and "name" are the only properties either specification requires: the "id" addresses the +artifact, the "version" is what consumers pin to, and the "name" is what a user recognizes it by in a +list. Metadata missing any of them cannot be published as a usable Feature or Template.`, + References: []string{ + "https://containers.dev/implementors/features/#devcontainer-featurejson-properties", + "https://containers.dev/implementors/templates/#devcontainer-templatejson-properties", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{""}, + Check: checkMissingRequiredProps, } func checkMissingRequiredProps(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_workspace_mount_folder.go b/rules/missing_workspace_mount_folder.go index c59d90e..246589c 100644 --- a/rules/missing_workspace_mount_folder.go +++ b/rules/missing_workspace_mount_folder.go @@ -13,10 +13,18 @@ import ( var MissingWorkspaceMountFolder = &linter.Rule{ ID: "missing-workspace-mount-folder", Description: `disallow a devcontainer.json using "image" or "build" that sets only one of "workspaceMount" or "workspaceFolder"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingWorkspaceMountFolder, + LongDescription: `The two properties describe opposite ends of the same override: "workspaceMount" says where the source +code is mounted, "workspaceFolder" says which path inside the container the tooling opens. The reference +documents each as requiring the other, because setting one alone either mounts the source somewhere +nothing opens, or opens a path nothing is mounted at.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", + "https://containers.dev/implementors/spec/#workspacefolder-and-workspacemount", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkMissingWorkspaceMountFolder, } func checkMissingWorkspaceMountFolder(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_app_port.go b/rules/no_app_port.go index dc9bbd5..04a5097 100644 --- a/rules/no_app_port.go +++ b/rules/no_app_port.go @@ -8,10 +8,18 @@ import "github.com/bare-devcontainer/decolint/linter" var NoAppPort = &linter.Rule{ ID: "no-app-port", Description: `disallow the legacy "appPort" property in favor of "forwardPorts"`, - Category: linter.CategoryStyle, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/appPort"}, - Check: checkNoAppPort, + LongDescription: `"appPort" publishes the port the way Docker does: it is fixed when the container is created, and the +application has to listen on all interfaces rather than just "localhost" to be reachable. A forwarded +port instead looks like "localhost" to the application and can be changed without recreating the +container, which is why the reference recommends "forwardPorts" in most cases.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", + "https://containers.dev/implementors/json_reference/#publishing-vs-forwarding-ports", + }, + Category: linter.CategoryStyle, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/appPort"}, + Check: checkNoAppPort, } func checkNoAppPort(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_bind_mount.go b/rules/no_bind_mount.go index f087d4d..4d4ca32 100644 --- a/rules/no_bind_mount.go +++ b/rules/no_bind_mount.go @@ -10,11 +10,19 @@ import ( var NoBindMount = &linter.Rule{ ID: "no-bind-mount", Description: `disallow "bind" type entries in "mounts", which GitHub Codespaces silently ignores except for the Docker socket`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformCodespaces}, - Paths: []string{"/mounts/*"}, - Check: checkNoBindMount, + LongDescription: `A codespace runs on a machine in the cloud, where the host path a bind mount points at does not exist, so +Codespaces documents that it ignores "bind" mounts apart from the Docker socket. The mount is dropped +without an error and the container starts missing the data it expects. Volume mounts are honored, so use +"type=volume" for anything that only has to persist across rebuilds.`, + References: []string{ + "https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#github-codespaces", + "https://containers.dev/implementors/spec/#mounts", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Paths: []string{"/mounts/*"}, + Check: checkNoBindMount, } func checkNoBindMount(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_cap_add_all.go b/rules/no_cap_add_all.go index c3505da..80f2ea5 100644 --- a/rules/no_cap_add_all.go +++ b/rules/no_cap_add_all.go @@ -12,10 +12,18 @@ import ( var NoCapAddAll = &linter.Rule{ ID: "no-cap-add-all", Description: `disallow granting all Linux capabilities via an "ALL" entry in the "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/capAdd/*", "/runArgs"}, - Check: checkNoCapAddAll, + LongDescription: `Linux capabilities split root's powers into units a container can be granted individually, and the runtime +withholds the dangerous ones by default. "ALL" hands them all over, including capabilities such as +"SYS_ADMIN" and "SYS_MODULE" that let a process reconfigure the host kernel and escape the container. +"capAdd" exists to name the one or two a workload actually needs, e.g. "SYS_PTRACE" for a debugger.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.docker.com/engine/security/#linux-kernel-capabilities", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/capAdd/*", "/runArgs"}, + Check: checkNoCapAddAll, } func checkNoCapAddAll(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_docker_socket_mount.go b/rules/no_docker_socket_mount.go index df903eb..dcc3252 100644 --- a/rules/no_docker_socket_mount.go +++ b/rules/no_docker_socket_mount.go @@ -14,10 +14,19 @@ import ( var NoDockerSocketMount = &linter.Rule{ ID: "no-docker-socket-mount", Description: `disallow bind-mounting the host's Docker socket via a devcontainer.json's "mounts" or "runArgs", which grants the container root-equivalent control over the host`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/mounts/*", "/runArgs/*"}, - Check: checkNoDockerSocketMount, + LongDescription: `The Docker socket is the daemon's full API, and the daemon runs as root on the host. Anything that can +reach the socket can start a container that mounts the host's filesystem, so mounting it into the dev +container hands root-equivalent control of the host to every process inside — including code the +project's own build fetches. When the container genuinely needs Docker, a Docker-in-Docker Feature or a +rootless daemon keeps that access inside the container.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.docker.com/engine/security/#docker-daemon-attack-surface", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/mounts/*", "/runArgs/*"}, + Check: checkNoDockerSocketMount, } func checkNoDockerSocketMount(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_host_port_format.go b/rules/no_host_port_format.go index 31fd1d6..fbc6e3b 100644 --- a/rules/no_host_port_format.go +++ b/rules/no_host_port_format.go @@ -15,11 +15,19 @@ import ( var NoHostPortFormat = &linter.Rule{ ID: "no-host-port-format", Description: `disallow "host:port" entries in "forwardPorts" and "portsAttributes", which GitHub Codespaces does not support`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformCodespaces}, - Paths: []string{"/forwardPorts/*", "/portsAttributes/*"}, - Check: checkNoHostPortFormat, + LongDescription: `The "host:port" form forwards a port from another container in a Docker Compose project (e.g. "db:5432") +rather than from the primary one. Codespaces documents that it does not support that variation of either +property, so the entry is ignored there and the port is not forwarded. A bare port number, which refers +to the primary container, works everywhere.`, + References: []string{ + "https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#github-codespaces", + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Paths: []string{"/forwardPorts/*", "/portsAttributes/*"}, + Check: checkNoHostPortFormat, } func checkNoHostPortFormat(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_image_latest.go b/rules/no_image_latest.go index 5df6680..c5b7b07 100644 --- a/rules/no_image_latest.go +++ b/rules/no_image_latest.go @@ -13,10 +13,17 @@ import ( var NoImageLatest = &linter.Rule{ ID: "no-image-latest", Description: `disallow container images without an explicit tag or with the "latest" tag`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, - Check: checkNoImageLatest, + 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.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/image"}, + Check: checkNoImageLatest, } func checkNoImageLatest(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_privileged_container.go b/rules/no_privileged_container.go index fb9df83..fb9ff3e 100644 --- a/rules/no_privileged_container.go +++ b/rules/no_privileged_container.go @@ -12,10 +12,19 @@ import ( var NoPrivilegedContainer = &linter.Rule{ ID: "no-privileged-container", Description: `disallow running the container in privileged mode via the "privileged" property or a "--privileged" entry in "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/privileged", "/runArgs/*"}, - Check: checkNoPrivilegedContainer, + LongDescription: `A privileged container gets every Linux capability, unconfined seccomp and LSM profiles, and access to all +host devices. That removes essentially every boundary between the container and the host, so any code +running in it — including a compromised dependency pulled in by the project's own build — can take over +the machine. Docker-in-Docker is the usual reason it is set; a Feature that provides it, or the specific +capabilities and devices the workload needs, is a far narrower grant.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.docker.com/engine/security/#docker-daemon-attack-surface", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/privileged", "/runArgs/*"}, + Check: checkNoPrivilegedContainer, } func checkNoPrivilegedContainer(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_seccomp_override.go b/rules/no_seccomp_override.go index 1e7fd00..ce822bd 100644 --- a/rules/no_seccomp_override.go +++ b/rules/no_seccomp_override.go @@ -16,10 +16,19 @@ import ( var NoSeccompOverride = &linter.Rule{ ID: "no-seccomp-override", Description: `disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs/*"}, - Check: checkNoSeccompOverride, + LongDescription: `The runtime's default seccomp profile blocks the syscalls containers do not need, several of which have +featured in container escapes. Pointing "seccomp" at a profile of your own replaces that default +wholesale, and a hand-written profile is rarely reviewed as carefully or updated as the kernel gains new +syscalls. Keep the default unless the workload provably needs more, and review the replacement if it +does.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.docker.com/engine/security/seccomp/", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/securityOpt/*", "/runArgs/*"}, + Check: checkNoSeccompOverride, } func checkNoSeccompOverride(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_seccomp_unconfined.go b/rules/no_seccomp_unconfined.go index ddc9941..4350f0b 100644 --- a/rules/no_seccomp_unconfined.go +++ b/rules/no_seccomp_unconfined.go @@ -12,10 +12,18 @@ import ( var NoSeccompUnconfined = &linter.Rule{ ID: "no-seccomp-unconfined", Description: `disallow disabling seccomp confinement via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" entry in a devcontainer.json's "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs"}, - Check: checkNoSeccompUnconfined, + LongDescription: `"seccomp=unconfined" turns off syscall filtering entirely, exposing the whole kernel API — including the +calls the default profile blocks precisely because they have been used to break out of containers. The +setting is most often copied from debugger instructions, where granting the "SYS_PTRACE" capability is +enough on current runtimes.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.docker.com/engine/security/seccomp/", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/securityOpt/*", "/runArgs"}, + Check: checkNoSeccompUnconfined, } func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_extension_version.go b/rules/pin_extension_version.go index 91c80db..82c7642 100644 --- a/rules/pin_extension_version.go +++ b/rules/pin_extension_version.go @@ -15,11 +15,19 @@ import ( var PinExtensionVersion = &linter.Rule{ ID: "pin-extension-version", Description: `disallow a "customizations.vscode.extensions" entry without an explicit pinned version`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, - Paths: []string{"/customizations/vscode/extensions/*"}, - Check: checkPinExtensionVersion, + LongDescription: `An extension ID on its own installs whatever the marketplace publishes at the moment the container is +created, so two developers on the same devcontainer.json can end up with different formatters, linters, or +language server versions — and an extension update can change the environment without any commit. +Appending a version ("publisher.name@1.2.3") makes the editor tooling as pinned as the rest of the image.`, + References: []string{ + "https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#visual-studio-code", + "https://code.visualstudio.com/docs/configure/extensions/extension-marketplace", + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, + Paths: []string{"/customizations/vscode/extensions/*"}, + Check: checkPinExtensionVersion, } func checkPinExtensionVersion(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_feature_version.go b/rules/pin_feature_version.go index 6535582..d7c0092 100644 --- a/rules/pin_feature_version.go +++ b/rules/pin_feature_version.go @@ -16,10 +16,18 @@ import ( var PinFeatureVersion = &linter.Rule{ ID: "pin-feature-version", Description: `disallow a Feature reference without an explicit version or with the "latest" version`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/features"}, - Check: checkPinFeatureVersion, + LongDescription: `A Feature reference with no version resolves to "latest", so the container installs whatever the Feature's +author published most recently — the tooling it sets up can change under the project without the +devcontainer.json changing at all. Features are published under their full version as well as +"major.minor" and "major" tags, so a reference can be pinned as tightly as the project wants.`, + References: []string{ + "https://containers.dev/implementors/features-distribution/#versioning", + "https://containers.dev/implementors/features/#referencing-a-feature", + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/features"}, + Check: checkPinFeatureVersion, } func checkPinFeatureVersion(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_image_digest.go b/rules/pin_image_digest.go index 434f06d..0db84f2 100644 --- a/rules/pin_image_digest.go +++ b/rules/pin_image_digest.go @@ -21,10 +21,18 @@ var digestSuffix = regexp.MustCompile(`@[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9 var PinImageDigest = &linter.Rule{ ID: "pin-image-digest", Description: `disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...")`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, - Check: checkPinImageDigest, + LongDescription: `A tag is a mutable pointer: the publisher can move even a fully specified one to different bits, and a +registry can serve a different image for the same tag on a different day. A digest names the content +itself, so "image@sha256:..." always resolves to the exact image the project was tested with, and the +client verifies what it pulled against it.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", + "https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests", + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/image"}, + Check: checkPinImageDigest, } func checkPinImageDigest(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_cap_drop_all.go b/rules/require_cap_drop_all.go index 5bd459c..336d436 100644 --- a/rules/require_cap_drop_all.go +++ b/rules/require_cap_drop_all.go @@ -12,10 +12,18 @@ import ( var RequireCapDropAll = &linter.Rule{ ID: "require-cap-drop-all", Description: `require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", dropping every Linux capability`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireCapDropAll, + LongDescription: `Container runtimes grant a default set of capabilities that a dev container almost never uses: raw network +access, changing file ownership, or binding privileged ports. Dropping all of them and adding back only +what the workload needs ("capAdd") means a process that is compromised inherits no privilege the project +never asked for.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.docker.com/engine/security/#linux-kernel-capabilities", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkRequireCapDropAll, } func checkRequireCapDropAll(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_no_new_privileges.go b/rules/require_no_new_privileges.go index ce3c718..98ff1ce 100644 --- a/rules/require_no_new_privileges.go +++ b/rules/require_no_new_privileges.go @@ -13,10 +13,18 @@ import ( var RequireNoNewPrivileges = &linter.Rule{ ID: "require-no-new-privileges", Description: `require "no-new-privileges" to be set via a devcontainer.json's "securityOpt" property, or a "--security-opt no-new-privileges..." entry in "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireNoNewPrivileges, + LongDescription: `Without this option a process in the container can still gain privileges it was not started with, by +executing a setuid binary — which undercuts the point of running as a non-root user. Setting it raises the +kernel's "no_new_privs" bit, which every child process inherits and none can clear, so the container's +privileges can only ever shrink.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", + "https://docs.kernel.org/userspace-api/no_new_privs.html", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkRequireNoNewPrivileges, } func checkRequireNoNewPrivileges(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_non_root.go b/rules/require_non_root.go index 02aa4b5..4b76f78 100644 --- a/rules/require_non_root.go +++ b/rules/require_non_root.go @@ -16,10 +16,18 @@ import ( var RequireNonRoot = &linter.Rule{ ID: "require-non-root", Description: `require "remoteUser" or, if unset, "containerUser" to be set to a non-root user`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireNonRoot, + LongDescription: `"remoteUser" defaults to whatever user the container runs as, which for most images is root. Everything +the developer's session drives then runs as root: lifecycle scripts, terminals, and the language servers +and build tools the editor starts, so a compromised dependency runs with full control of the container. +Naming an unprivileged user — as the specification's own images do — costs nothing and contains it.`, + References: []string{ + "https://containers.dev/implementors/json_reference/#remoteuser", + "https://containers.dev/implementors/spec/#users", + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkRequireNonRoot, } func checkRequireNonRoot(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/undefined_template_option.go b/rules/undefined_template_option.go index 2b76fc1..cdc96db 100644 --- a/rules/undefined_template_option.go +++ b/rules/undefined_template_option.go @@ -16,10 +16,18 @@ import ( var UndefinedTemplateOption = &linter.Rule{ ID: "undefined-template-option", Description: "disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Template}, - Paths: []string{""}, - Check: checkUndefinedTemplateOption, + LongDescription: `Applying a Template replaces each "${templateOption:name}" with the value the user chose for the option of +that name. A reference to an option that "options" does not declare is never prompted for, and the +reference implementation substitutes the empty string for it, so a typo silently produces an empty value +in the applied files instead of an error.`, + References: []string{ + "https://containers.dev/implementors/templates/#the-options-property", + "https://github.com/devcontainers/cli", + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Template}, + Paths: []string{""}, + Check: checkUndefinedTemplateOption, } func checkUndefinedTemplateOption(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/unused_template_option.go b/rules/unused_template_option.go index 73c0418..bd828af 100644 --- a/rules/unused_template_option.go +++ b/rules/unused_template_option.go @@ -13,10 +13,17 @@ import ( var UnusedTemplateOption = &linter.Rule{ ID: "unused-template-option", Description: "disallow a Template option that no file in the Template references", - Category: linter.CategoryStyle, - FileTypes: []linter.FileType{linter.Template}, - Paths: []string{"/options"}, - Check: checkUnusedTemplateOption, + LongDescription: `An option only takes effect where a file substitutes it as "${templateOption:name}". One that nothing +references is still presented to the user when the Template is applied, so it asks a question whose +answer changes nothing — usually a leftover from a removed file or a renamed reference.`, + References: []string{ + "https://containers.dev/implementors/templates/#the-options-property", + "https://containers.dev/implementors/templates/#option-resolution", + }, + Category: linter.CategoryStyle, + FileTypes: []linter.FileType{linter.Template}, + Paths: []string{"/options"}, + Check: checkUnusedTemplateOption, } func checkUnusedTemplateOption(ctx *linter.Context, node *linter.Node) []linter.Finding { From 19b13745b676d13fb42c21f4d7e89d48c3ffadf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:35:04 +0000 Subject: [PATCH 02/24] refactor: move CLI-only packages under cmd/decolint discovery, format and substitute are imported by package main and nothing else. Nesting them under the binary keeps the module root to the packages that are usable on their own: linter, rules, feature and the ocitest helper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- {discovery => cmd/decolint/discovery}/discovery.go | 0 {discovery => cmd/decolint/discovery}/discovery_test.go | 0 .../discovery}/testdata/feature/devcontainer-feature.json | 0 .../testdata/project/.devcontainer/devcontainer.json | 0 .../testdata/project/.devcontainer/go/devcontainer.json | 0 .../decolint/discovery}/testdata/rootfile/.devcontainer.json | 0 .../template-no-devcontainer/devcontainer-template.json | 0 .../discovery}/testdata/template-rootfile/.devcontainer.json | 0 .../testdata/template-rootfile/devcontainer-template.json | 0 .../template-subfolder/.devcontainer/go/devcontainer.json | 0 .../testdata/template-subfolder/devcontainer-template.json | 0 .../testdata/template/.devcontainer/devcontainer.json | 0 .../discovery}/testdata/template/devcontainer-template.json | 0 cmd/decolint/format.go | 2 +- {format => cmd/decolint/format}/doc.go | 0 {format => cmd/decolint/format}/github.go | 0 {format => cmd/decolint/format}/github_test.go | 0 {format => cmd/decolint/format}/json.go | 0 {format => cmd/decolint/format}/json_test.go | 0 {format => cmd/decolint/format}/sarif.go | 0 {format => cmd/decolint/format}/sarif_test.go | 0 {format => cmd/decolint/format}/text.go | 0 {format => cmd/decolint/format}/text_test.go | 0 cmd/decolint/format_test.go | 2 +- cmd/decolint/main.go | 4 ++-- cmd/decolint/main_test.go | 2 +- {substitute => cmd/decolint/substitute}/substitute.go | 0 {substitute => cmd/decolint/substitute}/substitute_test.go | 0 28 files changed, 5 insertions(+), 5 deletions(-) rename {discovery => cmd/decolint/discovery}/discovery.go (100%) rename {discovery => cmd/decolint/discovery}/discovery_test.go (100%) rename {discovery => cmd/decolint/discovery}/testdata/feature/devcontainer-feature.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/project/.devcontainer/devcontainer.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/project/.devcontainer/go/devcontainer.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/rootfile/.devcontainer.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template-no-devcontainer/devcontainer-template.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template-rootfile/.devcontainer.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template-rootfile/devcontainer-template.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template-subfolder/.devcontainer/go/devcontainer.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template-subfolder/devcontainer-template.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template/.devcontainer/devcontainer.json (100%) rename {discovery => cmd/decolint/discovery}/testdata/template/devcontainer-template.json (100%) rename {format => cmd/decolint/format}/doc.go (100%) rename {format => cmd/decolint/format}/github.go (100%) rename {format => cmd/decolint/format}/github_test.go (100%) rename {format => cmd/decolint/format}/json.go (100%) rename {format => cmd/decolint/format}/json_test.go (100%) rename {format => cmd/decolint/format}/sarif.go (100%) rename {format => cmd/decolint/format}/sarif_test.go (100%) rename {format => cmd/decolint/format}/text.go (100%) rename {format => cmd/decolint/format}/text_test.go (100%) rename {substitute => cmd/decolint/substitute}/substitute.go (100%) rename {substitute => cmd/decolint/substitute}/substitute_test.go (100%) diff --git a/discovery/discovery.go b/cmd/decolint/discovery/discovery.go similarity index 100% rename from discovery/discovery.go rename to cmd/decolint/discovery/discovery.go diff --git a/discovery/discovery_test.go b/cmd/decolint/discovery/discovery_test.go similarity index 100% rename from discovery/discovery_test.go rename to cmd/decolint/discovery/discovery_test.go diff --git a/discovery/testdata/feature/devcontainer-feature.json b/cmd/decolint/discovery/testdata/feature/devcontainer-feature.json similarity index 100% rename from discovery/testdata/feature/devcontainer-feature.json rename to cmd/decolint/discovery/testdata/feature/devcontainer-feature.json diff --git a/discovery/testdata/project/.devcontainer/devcontainer.json b/cmd/decolint/discovery/testdata/project/.devcontainer/devcontainer.json similarity index 100% rename from discovery/testdata/project/.devcontainer/devcontainer.json rename to cmd/decolint/discovery/testdata/project/.devcontainer/devcontainer.json diff --git a/discovery/testdata/project/.devcontainer/go/devcontainer.json b/cmd/decolint/discovery/testdata/project/.devcontainer/go/devcontainer.json similarity index 100% rename from discovery/testdata/project/.devcontainer/go/devcontainer.json rename to cmd/decolint/discovery/testdata/project/.devcontainer/go/devcontainer.json diff --git a/discovery/testdata/rootfile/.devcontainer.json b/cmd/decolint/discovery/testdata/rootfile/.devcontainer.json similarity index 100% rename from discovery/testdata/rootfile/.devcontainer.json rename to cmd/decolint/discovery/testdata/rootfile/.devcontainer.json diff --git a/discovery/testdata/template-no-devcontainer/devcontainer-template.json b/cmd/decolint/discovery/testdata/template-no-devcontainer/devcontainer-template.json similarity index 100% rename from discovery/testdata/template-no-devcontainer/devcontainer-template.json rename to cmd/decolint/discovery/testdata/template-no-devcontainer/devcontainer-template.json diff --git a/discovery/testdata/template-rootfile/.devcontainer.json b/cmd/decolint/discovery/testdata/template-rootfile/.devcontainer.json similarity index 100% rename from discovery/testdata/template-rootfile/.devcontainer.json rename to cmd/decolint/discovery/testdata/template-rootfile/.devcontainer.json diff --git a/discovery/testdata/template-rootfile/devcontainer-template.json b/cmd/decolint/discovery/testdata/template-rootfile/devcontainer-template.json similarity index 100% rename from discovery/testdata/template-rootfile/devcontainer-template.json rename to cmd/decolint/discovery/testdata/template-rootfile/devcontainer-template.json diff --git a/discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json b/cmd/decolint/discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json similarity index 100% rename from discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json rename to cmd/decolint/discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json diff --git a/discovery/testdata/template-subfolder/devcontainer-template.json b/cmd/decolint/discovery/testdata/template-subfolder/devcontainer-template.json similarity index 100% rename from discovery/testdata/template-subfolder/devcontainer-template.json rename to cmd/decolint/discovery/testdata/template-subfolder/devcontainer-template.json diff --git a/discovery/testdata/template/.devcontainer/devcontainer.json b/cmd/decolint/discovery/testdata/template/.devcontainer/devcontainer.json similarity index 100% rename from discovery/testdata/template/.devcontainer/devcontainer.json rename to cmd/decolint/discovery/testdata/template/.devcontainer/devcontainer.json diff --git a/discovery/testdata/template/devcontainer-template.json b/cmd/decolint/discovery/testdata/template/devcontainer-template.json similarity index 100% rename from discovery/testdata/template/devcontainer-template.json rename to cmd/decolint/discovery/testdata/template/devcontainer-template.json diff --git a/cmd/decolint/format.go b/cmd/decolint/format.go index bc0abab..904480b 100644 --- a/cmd/decolint/format.go +++ b/cmd/decolint/format.go @@ -5,7 +5,7 @@ import ( "io" "strings" - "github.com/bare-devcontainer/decolint/format" + "github.com/bare-devcontainer/decolint/cmd/decolint/format" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" ) diff --git a/format/doc.go b/cmd/decolint/format/doc.go similarity index 100% rename from format/doc.go rename to cmd/decolint/format/doc.go diff --git a/format/github.go b/cmd/decolint/format/github.go similarity index 100% rename from format/github.go rename to cmd/decolint/format/github.go diff --git a/format/github_test.go b/cmd/decolint/format/github_test.go similarity index 100% rename from format/github_test.go rename to cmd/decolint/format/github_test.go diff --git a/format/json.go b/cmd/decolint/format/json.go similarity index 100% rename from format/json.go rename to cmd/decolint/format/json.go diff --git a/format/json_test.go b/cmd/decolint/format/json_test.go similarity index 100% rename from format/json_test.go rename to cmd/decolint/format/json_test.go diff --git a/format/sarif.go b/cmd/decolint/format/sarif.go similarity index 100% rename from format/sarif.go rename to cmd/decolint/format/sarif.go diff --git a/format/sarif_test.go b/cmd/decolint/format/sarif_test.go similarity index 100% rename from format/sarif_test.go rename to cmd/decolint/format/sarif_test.go diff --git a/format/text.go b/cmd/decolint/format/text.go similarity index 100% rename from format/text.go rename to cmd/decolint/format/text.go diff --git a/format/text_test.go b/cmd/decolint/format/text_test.go similarity index 100% rename from format/text_test.go rename to cmd/decolint/format/text_test.go diff --git a/cmd/decolint/format_test.go b/cmd/decolint/format_test.go index c8b4d1d..b246956 100644 --- a/cmd/decolint/format_test.go +++ b/cmd/decolint/format_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/bare-devcontainer/decolint/format" + "github.com/bare-devcontainer/decolint/cmd/decolint/format" "github.com/bare-devcontainer/decolint/rules" "github.com/google/go-cmp/cmp" ) diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index 4cc7788..31050a2 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -16,11 +16,11 @@ import ( "strings" "syscall" - "github.com/bare-devcontainer/decolint/discovery" + "github.com/bare-devcontainer/decolint/cmd/decolint/discovery" "github.com/bare-devcontainer/decolint/feature" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" - "github.com/bare-devcontainer/decolint/substitute" + "github.com/bare-devcontainer/decolint/cmd/decolint/substitute" ) // progName is the program name, used in the flag set, usage text, and error messages. diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index d2a9647..c03da01 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -14,7 +14,7 @@ import ( "strings" "testing" - "github.com/bare-devcontainer/decolint/discovery" + "github.com/bare-devcontainer/decolint/cmd/decolint/discovery" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/ocitest" "github.com/bare-devcontainer/decolint/rules" diff --git a/substitute/substitute.go b/cmd/decolint/substitute/substitute.go similarity index 100% rename from substitute/substitute.go rename to cmd/decolint/substitute/substitute.go diff --git a/substitute/substitute_test.go b/cmd/decolint/substitute/substitute_test.go similarity index 100% rename from substitute/substitute_test.go rename to cmd/decolint/substitute/substitute_test.go From e12428cbb46962fb759404b7cc2b0938fbafe68f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 14:36:27 +0000 Subject: [PATCH 03/24] feat: publish the rule reference as a documentation site The rationale and references each rule carried were compiled into the binary and repeated in every SARIF log, and neither could show what the rule accepts or rejects. Move them to a GitHub Pages site built from docs/, and keep only the one-line Description in the binary, alongside a documentation address derived from the rule ID. Each rule page adds Bad and Good examples, which rules/doc_test.go verifies by linting: the Bad example must report the rule and the Good one must report nothing, so an example cannot drift from the rule it documents. The two rules whose examples differ in a file's permission bits or its absence are listed as unverifiable rather than skipped silently. SARIF now carries helpUri and a help message linking to the page. The README rules table is regenerated from the rule declarations, which also picks up descriptions it had drifted from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- .github/workflows/pages.yml | 54 +++ CONTRIBUTING.md | 88 ++++- README.md | 70 ++-- cmd/decolint/format.go | 9 +- cmd/decolint/format/sarif.go | 49 +-- cmd/decolint/format/sarif_test.go | 46 +-- cmd/decolint/format_test.go | 9 +- cmd/decolint/main.go | 16 +- cmd/decolint/main_test.go | 3 +- cmd/decolint/opts.go | 2 +- docs/_config.yml | 27 ++ docs/getting-started.md | 156 ++++++++ docs/index.md | 38 ++ docs/rules/conflicting-container-def.md | 46 +++ .../feature-install-script-not-executable.md | 40 ++ docs/rules/id-dir-mismatch.md | 46 +++ docs/rules/index.md | 63 ++++ docs/rules/invalid-semver.md | 42 +++ docs/rules/missing-build-dockerfile.md | 44 +++ docs/rules/missing-compose-service.md | 43 +++ docs/rules/missing-container-def.md | 41 ++ docs/rules/missing-feature-install-script.md | 41 ++ docs/rules/missing-required-props.md | 41 ++ docs/rules/missing-workspace-mount-folder.md | 42 +++ docs/rules/no-app-port.md | 40 ++ docs/rules/no-bind-mount.md | 53 +++ docs/rules/no-cap-add-all.md | 42 +++ docs/rules/no-docker-socket-mount.md | 51 +++ docs/rules/no-host-port-format.md | 41 ++ docs/rules/no-image-latest.md | 37 ++ docs/rules/no-privileged-container.md | 47 +++ docs/rules/no-seccomp-override.md | 46 +++ docs/rules/no-seccomp-unconfined.md | 42 +++ docs/rules/pin-extension-version.md | 49 +++ docs/rules/pin-feature-version.md | 45 +++ docs/rules/pin-image-digest.md | 39 ++ docs/rules/require-cap-drop-all.md | 46 +++ docs/rules/require-no-new-privileges.md | 41 ++ docs/rules/require-non-root.md | 45 +++ docs/rules/undefined-template-option.md | 78 ++++ docs/rules/unused-template-option.md | 76 ++++ linter/rule.go | 7 - rules/conflicting_container_def.go | 16 +- rules/doc_test.go | 353 +++++++++++++++++- rules/docs.go | 12 + .../feature_install_script_not_executable.go | 14 +- rules/id_dir_mismatch.go | 16 +- rules/invalid_semver.go | 15 +- rules/missing_build_dockerfile.go | 15 +- rules/missing_compose_service.go | 15 +- rules/missing_container_def.go | 15 +- rules/missing_feature_install_script.go | 15 +- rules/missing_required_props.go | 15 +- rules/missing_workspace_mount_folder.go | 16 +- rules/no_app_port.go | 16 +- rules/no_bind_mount.go | 18 +- rules/no_cap_add_all.go | 16 +- rules/no_docker_socket_mount.go | 17 +- rules/no_host_port_format.go | 18 +- rules/no_image_latest.go | 15 +- rules/no_privileged_container.go | 17 +- rules/no_seccomp_override.go | 17 +- rules/no_seccomp_unconfined.go | 16 +- rules/pin_extension_version.go | 18 +- rules/pin_feature_version.go | 16 +- rules/pin_image_digest.go | 16 +- rules/require_cap_drop_all.go | 16 +- rules/require_no_new_privileges.go | 16 +- rules/require_non_root.go | 16 +- rules/undefined_template_option.go | 16 +- rules/unused_template_option.go | 15 +- 71 files changed, 2197 insertions(+), 480 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/_config.yml create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/rules/conflicting-container-def.md create mode 100644 docs/rules/feature-install-script-not-executable.md create mode 100644 docs/rules/id-dir-mismatch.md create mode 100644 docs/rules/index.md create mode 100644 docs/rules/invalid-semver.md create mode 100644 docs/rules/missing-build-dockerfile.md create mode 100644 docs/rules/missing-compose-service.md create mode 100644 docs/rules/missing-container-def.md create mode 100644 docs/rules/missing-feature-install-script.md create mode 100644 docs/rules/missing-required-props.md create mode 100644 docs/rules/missing-workspace-mount-folder.md create mode 100644 docs/rules/no-app-port.md create mode 100644 docs/rules/no-bind-mount.md create mode 100644 docs/rules/no-cap-add-all.md create mode 100644 docs/rules/no-docker-socket-mount.md create mode 100644 docs/rules/no-host-port-format.md create mode 100644 docs/rules/no-image-latest.md create mode 100644 docs/rules/no-privileged-container.md create mode 100644 docs/rules/no-seccomp-override.md create mode 100644 docs/rules/no-seccomp-unconfined.md create mode 100644 docs/rules/pin-extension-version.md create mode 100644 docs/rules/pin-feature-version.md create mode 100644 docs/rules/pin-image-digest.md create mode 100644 docs/rules/require-cap-drop-all.md create mode 100644 docs/rules/require-no-new-privileges.md create mode 100644 docs/rules/require-non-root.md create mode 100644 docs/rules/undefined-template-option.md create mode 100644 docs/rules/unused-template-option.md create mode 100644 rules/docs.go diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..baadb02 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Pages + +# Builds the documentation site in docs/ and publishes it to GitHub Pages. A pull request builds it +# too, without deploying, so a page that breaks the build is caught before it is merged. +on: + push: + branches: [main] + paths: + - docs/** + - .github/workflows/pages.yml + pull_request: + paths: + - docs/** + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: {} + +# One deployment at a time, and never cancel one in flight: a half-published site is worse than a +# late one. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + # The site's url and baseurl are set in docs/_config.yml rather than taken from the Pages API, + # so the build needs no repository settings and runs the same on a fork. + - uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13 + with: + source: ./docs + destination: ./_site + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + + deploy: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f472bb..82fe9da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,12 +34,10 @@ and calls `Check` for every value matching one of the paths; a `*` segment matches any object member name or array index, and the empty string matches the document root. -Besides the one-line `Description` of what it checks, every rule -carries a `LongDescription` explaining why the configuration it -reports is a problem, and at least one `References` URL pointing at -the specification, documentation, or implementation that justifies it. -Both are shown by `decolint -explain ` and in the SARIF -output, so write them for the user who just hit the finding. +The rule itself carries only the one-line `Description` of what it +checks; the reasoning and the examples live on the documentation site +(see [Documenting a rule](#documenting-a-rule) below), which every +finding links to. A rule's default severity is not set individually; it comes entirely from its category (see `categoryDefaultSeverities` in @@ -53,15 +51,13 @@ package rules import "github.com/bare-devcontainer/decolint/linter" var MyRule = &linter.Rule{ - ID: "my-rule", - Description: "...", - LongDescription: "...", - References: []string{"https://containers.dev/implementors/json_reference/"}, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: nil, // applies to every platform - Paths: []string{"/mounts/*"}, - Check: checkMyRule, + ID: "my-rule", + Description: "...", + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: nil, // applies to every platform + Paths: []string{"/mounts/*"}, + Check: checkMyRule, } func checkMyRule(ctx *linter.Context, node *linter.Node) []linter.Finding { @@ -77,6 +73,68 @@ including for the table-driven tests each rule ships with. When a new rule lands, also add a row for it to the table in [README.md](README.md#rules). +## Documenting a rule + +Every rule has a page under [`docs/rules/`](docs/rules/), named after +its ID, which is where the reasoning and the examples live. It is what +`decolint -explain`, the SARIF output, and every code scanning alert +link to, so write it for the user who just hit the finding. + +````markdown +--- +title: my-rule +category: correctness +platforms: [] +file_types: [devcontainer] +description: >- + disallow ... +--- + +*{{ page.description }}* + +## Why + +What goes wrong in the configuration this reports, and what to do +instead. + +## Bad + +```jsonc +{ ... } +``` + +## Good + +```jsonc +{ ... } +``` + +## References + +- +```` + +The front matter must match the rule's Go declaration, and the tests +in [`rules/doc_test.go`](rules/doc_test.go) enforce it: they lint the +Bad example and require it to report the rule, lint the Good example +and require it to report nothing, and check that every rule has a page +and every page at least one `https` reference. + +Two things to know when writing the examples: + +- An example needing more than one file names each with a + ``### `path` `` heading before its block; without one, a block is + linted as the rule's own file type. Set `example_dir` in the front + matter when the rule reads the name of the directory it is in. +- An example whose Bad and Good differ in something other than file + contents — a permission bit, or a missing file — cannot be linted. + Write it in whatever form shows the difference, set + `example_verify: false`, and add the rule to `unverifiableExamples` + in the test. + +Add the new page to the table in +[`docs/rules/index.md`](docs/rules/index.md) too. + When implementing or reviewing rules, consult the Dev Container specification at [containers.dev](https://containers.dev/) to confirm the behavior matches the spec: diff --git a/README.md b/README.md index 53739d1..8777818 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ decolint is a linter for [Dev Container](https://containers.dev/) configuration - Feature (`devcontainer-feature.json`) - Template (`devcontainer-template.json`) -It checks for common mistakes, security issues, and best practices in these files. See [Rules](#rules) for the list of checks decolint performs. +It checks for common mistakes, security issues, and best practices in these files. See [Rules](#rules) for the list of checks decolint performs, and the [documentation site](https://bare-devcontainer.github.io/decolint/) for a guided introduction and the reasoning behind each rule. A container's final configuration comes not only from its own file but also from the base image and the Features it uses. With [`-merge`](#merging), decolint resolves all of these as they would be at runtime and lints the fully merged configuration. @@ -92,7 +92,7 @@ The remaining flags perform a one-off action and exit; run | `-config ` | use the config file at `` instead of [auto-discovery](#config-file) | | `-init` | write a new `.decolint.jsonc` listing every rule at its default severity | | `-rules` | print the built-in rules as a Markdown table | -| `-explain ` | print what one rule checks, why, and where it is documented | +| `-explain ` | print what one rule checks and where it is documented | | `-version` | print version information | | `-help` | print usage | @@ -283,16 +283,18 @@ can also optionally target specific platforms (see [Target platforms](#target-platforms)); a rule with no target platform applies to all platforms. -Each rule also explains why it exists and links to the specification -or documentation it is based on. To read that for a single rule: +Every rule has a page on the [documentation +site](https://bare-devcontainer.github.io/decolint/rules/) covering +why it exists, the configuration it accepts and rejects, and the +specification it is based on. To look one up from the command line: ```console decolint -explain no-privileged-container ``` -The [SARIF output](#output-formats) carries the same explanation and -links for every rule it reports, so they show up on the alert in -GitHub Code Scanning. +The [SARIF output](#output-formats) links every rule it reports to the +same page, so the reasoning is one click away from the alert in GitHub +Code Scanning. ### Rule categories @@ -310,33 +312,33 @@ Only `correctness` runs by default; the rest are `off` until enabled: | ID | Category | Platform | Description | | --- | --- | --- | --- | -| `conflicting-container-def` | `correctness` | (all) | disallow a devcontainer.json that defines more than one of `image`, `build`, or `dockerComposeFile` | -| `feature-install-script-not-executable` | `correctness` | (all) | disallow a Feature's `install.sh` that lacks executable permission bits | -| `id-dir-mismatch` | `correctness` | (all) | disallow a Feature's or Template's `id` that does not match the name of its containing directory | -| `invalid-semver` | `correctness` | (all) | disallow a Feature's or Template's `version` that is not a valid semantic version | -| `missing-build-dockerfile` | `correctness` | (all) | disallow a devcontainer.json `build` object that is missing `dockerfile` | -| `missing-compose-service` | `correctness` | (all) | disallow a devcontainer.json that sets `dockerComposeFile` without `service` | -| `missing-container-def` | `correctness` | (all) | disallow a devcontainer.json that defines none of `image`, `build`, or `dockerComposeFile` | -| `missing-feature-install-script` | `correctness` | (all) | disallow a Feature directory without the required `install.sh` install script | -| `missing-required-props` | `correctness` | (all) | disallow a Feature's or Template's metadata that is missing a required property (`id`, `version`, or `name`) | -| `missing-workspace-mount-folder` | `correctness` | (all) | disallow a devcontainer.json using `image` or `build` that sets only one of `workspaceMount` or `workspaceFolder` | -| `no-bind-mount` | `correctness` | `codespaces` | disallow `bind` type entries in `mounts`, which GitHub Codespaces silently ignores except for the Docker socket | -| `no-host-port-format` | `correctness` | `codespaces` | disallow `host:port` entries in `forwardPorts` and `portsAttributes`, which GitHub Codespaces does not support | -| `undefined-template-option` | `correctness` | (all) | disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json | -| `no-cap-add-all` | `security` | (all) | disallow granting all Linux capabilities via an `ALL` entry in a devcontainer.json's or Feature's `capAdd` property, or a `--cap-add=ALL` entry in a devcontainer.json's `runArgs` | -| `no-docker-socket-mount` | `security` | (all) | disallow bind-mounting the host's Docker socket via a devcontainer.json's `mounts` or `runArgs`, which grants the container root-equivalent control over the host | -| `no-privileged-container` | `security` | (all) | disallow running the container in privileged mode via a devcontainer.json's or Feature's `privileged` property, or a `--privileged` entry in a devcontainer.json's `runArgs` | -| `no-seccomp-override` | `security` | (all) | disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's `securityOpt` property, or a `--security-opt seccomp=...` entry in a devcontainer.json's `runArgs` | -| `no-seccomp-unconfined` | `security` | (all) | disallow disabling seccomp confinement via a devcontainer.json's or Feature's `securityOpt` property, or a `--security-opt seccomp=unconfined` entry in a devcontainer.json's `runArgs` | -| `require-cap-drop-all` | `security` | (all) | require an `ALL` entry in a devcontainer.json's `capDrop` property, or a `--cap-drop=ALL` entry in `runArgs`, dropping every Linux capability | -| `require-no-new-privileges` | `security` | (all) | require `no-new-privileges` to be set via a devcontainer.json's `securityOpt` property, or a `--security-opt no-new-privileges...` entry in `runArgs` | -| `require-non-root` | `security` | (all) | require `remoteUser` or, if unset, `containerUser` to be set to a non-root user | -| `no-image-latest` | `reproducibility` | (all) | disallow container images without an explicit tag or with the `latest` tag | -| `pin-extension-version` | `reproducibility` | `vscode`, `codespaces` | disallow a `customizations.vscode.extensions` entry without an explicit pinned version | -| `pin-feature-version` | `reproducibility` | (all) | disallow a Feature reference without an explicit version or with the `latest` version | -| `pin-image-digest` | `reproducibility` | (all) | disallow an `image` property that does not pin the image by content digest (e.g. `image@sha256:...`) | -| `no-app-port` | `style` | (all) | disallow the legacy `appPort` property in favor of `forwardPorts` | -| `unused-template-option` | `style` | (all) | disallow a Template option that no file in the Template references | +| [`conflicting-container-def`](https://bare-devcontainer.github.io/decolint/rules/conflicting-container-def/) | `correctness` | (all) | disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile" | +| [`feature-install-script-not-executable`](https://bare-devcontainer.github.io/decolint/rules/feature-install-script-not-executable/) | `correctness` | (all) | disallow a Feature's `install.sh` that lacks executable permission bits | +| [`id-dir-mismatch`](https://bare-devcontainer.github.io/decolint/rules/id-dir-mismatch/) | `correctness` | (all) | disallow a Feature's or Template's "id" that does not match the name of its containing directory | +| [`invalid-semver`](https://bare-devcontainer.github.io/decolint/rules/invalid-semver/) | `correctness` | (all) | disallow a Feature's or Template's "version" that is not a valid semantic version | +| [`missing-build-dockerfile`](https://bare-devcontainer.github.io/decolint/rules/missing-build-dockerfile/) | `correctness` | (all) | disallow a devcontainer.json "build" object that is missing "dockerfile" | +| [`missing-compose-service`](https://bare-devcontainer.github.io/decolint/rules/missing-compose-service/) | `correctness` | (all) | disallow a devcontainer.json that sets "dockerComposeFile" without "service" | +| [`missing-container-def`](https://bare-devcontainer.github.io/decolint/rules/missing-container-def/) | `correctness` | (all) | disallow a devcontainer.json that defines none of "image", "build", or "dockerComposeFile" | +| [`missing-feature-install-script`](https://bare-devcontainer.github.io/decolint/rules/missing-feature-install-script/) | `correctness` | (all) | disallow a Feature directory without the required `install.sh` install script | +| [`missing-required-props`](https://bare-devcontainer.github.io/decolint/rules/missing-required-props/) | `correctness` | (all) | disallow a Feature's or Template's metadata that is missing a required property ("id", "version", or "name") | +| [`missing-workspace-mount-folder`](https://bare-devcontainer.github.io/decolint/rules/missing-workspace-mount-folder/) | `correctness` | (all) | disallow a devcontainer.json using "image" or "build" that sets only one of "workspaceMount" or "workspaceFolder" | +| [`no-bind-mount`](https://bare-devcontainer.github.io/decolint/rules/no-bind-mount/) | `correctness` | `codespaces` | disallow "bind" type entries in "mounts", which GitHub Codespaces silently ignores except for the Docker socket | +| [`no-host-port-format`](https://bare-devcontainer.github.io/decolint/rules/no-host-port-format/) | `correctness` | `codespaces` | disallow "host:port" entries in "forwardPorts" and "portsAttributes", which GitHub Codespaces does not support | +| [`undefined-template-option`](https://bare-devcontainer.github.io/decolint/rules/undefined-template-option/) | `correctness` | (all) | disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json | +| [`no-cap-add-all`](https://bare-devcontainer.github.io/decolint/rules/no-cap-add-all/) | `security` | (all) | disallow granting all Linux capabilities via an "ALL" entry in the "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's "runArgs" | +| [`no-docker-socket-mount`](https://bare-devcontainer.github.io/decolint/rules/no-docker-socket-mount/) | `security` | (all) | disallow bind-mounting the host's Docker socket via a devcontainer.json's "mounts" or "runArgs", which grants the container root-equivalent control over the host | +| [`no-privileged-container`](https://bare-devcontainer.github.io/decolint/rules/no-privileged-container/) | `security` | (all) | disallow running the container in privileged mode via the "privileged" property or a "--privileged" entry in "runArgs" | +| [`no-seccomp-override`](https://bare-devcontainer.github.io/decolint/rules/no-seccomp-override/) | `security` | (all) | disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs" | +| [`no-seccomp-unconfined`](https://bare-devcontainer.github.io/decolint/rules/no-seccomp-unconfined/) | `security` | (all) | disallow disabling seccomp confinement via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" entry in a devcontainer.json's "runArgs" | +| [`require-cap-drop-all`](https://bare-devcontainer.github.io/decolint/rules/require-cap-drop-all/) | `security` | (all) | require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", dropping every Linux capability | +| [`require-no-new-privileges`](https://bare-devcontainer.github.io/decolint/rules/require-no-new-privileges/) | `security` | (all) | require "no-new-privileges" to be set via a devcontainer.json's "securityOpt" property, or a "--security-opt no-new-privileges..." entry in "runArgs" | +| [`require-non-root`](https://bare-devcontainer.github.io/decolint/rules/require-non-root/) | `security` | (all) | require "remoteUser" or, if unset, "containerUser" to be set to a non-root user | +| [`no-image-latest`](https://bare-devcontainer.github.io/decolint/rules/no-image-latest/) | `reproducibility` | (all) | disallow container images without an explicit tag or with the "latest" tag | +| [`pin-extension-version`](https://bare-devcontainer.github.io/decolint/rules/pin-extension-version/) | `reproducibility` | `vscode`, `codespaces` | disallow a "customizations.vscode.extensions" entry without an explicit pinned version | +| [`pin-feature-version`](https://bare-devcontainer.github.io/decolint/rules/pin-feature-version/) | `reproducibility` | (all) | disallow a Feature reference without an explicit version or with the "latest" version | +| [`pin-image-digest`](https://bare-devcontainer.github.io/decolint/rules/pin-image-digest/) | `reproducibility` | (all) | disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...") | +| [`no-app-port`](https://bare-devcontainer.github.io/decolint/rules/no-app-port/) | `style` | (all) | disallow the legacy "appPort" property in favor of "forwardPorts" | +| [`unused-template-option`](https://bare-devcontainer.github.io/decolint/rules/unused-template-option/) | `style` | (all) | disallow a Template option that no file in the Template references | ## Suppressing findings diff --git a/cmd/decolint/format.go b/cmd/decolint/format.go index 904480b..6d083bf 100644 --- a/cmd/decolint/format.go +++ b/cmd/decolint/format.go @@ -40,11 +40,10 @@ func sarifRules() []format.SARIFRule { out := make([]format.SARIFRule, len(regs)) for i, reg := range regs { out[i] = format.SARIFRule{ - ID: reg.Rule.ID, - Description: reg.Rule.Description, - LongDescription: reg.Rule.LongDescription, - References: reg.Rule.References, - Category: reg.Rule.Category.String(), + ID: reg.Rule.ID, + Description: reg.Rule.Description, + Category: reg.Rule.Category.String(), + HelpURI: rules.DocsURL(reg.Rule.ID), } } return out diff --git a/cmd/decolint/format/sarif.go b/cmd/decolint/format/sarif.go index 7d75b9a..2b35bf1 100644 --- a/cmd/decolint/format/sarif.go +++ b/cmd/decolint/format/sarif.go @@ -17,11 +17,11 @@ import ( // SARIFRule describes one rule for the SARIF rule catalog. It mirrors the [linter.Rule] fields the // SARIF output needs, so this package does not depend on the rules package. type SARIFRule struct { - ID string - Description string - LongDescription string - References []string - Category string + ID string + Description string + Category string + // HelpURI is where the rule is documented in full. + HelpURI string } // SARIFFormat prints a SARIF 2.1.0 log, suitable for upload to GitHub Code Scanning. @@ -66,7 +66,7 @@ type sarifDriver struct { type sarifRuleDescriptor struct { ID string `json:"id"` ShortDescription sarifMessage `json:"shortDescription,omitzero"` - FullDescription sarifMessage `json:"fullDescription,omitzero"` + HelpURI string `json:"helpUri,omitzero"` Help sarifMultiformatMessage `json:"help,omitzero"` Properties sarifProperties `json:"properties,omitzero"` } @@ -138,7 +138,7 @@ func (f SARIFFormat) WriteIssues(w io.Writer, issues []linter.Issue) error { desc := sarifRuleDescriptor{ID: id} if r, ok := catalog[id]; ok { desc.ShortDescription = sarifMessage{Text: r.Description} - desc.FullDescription = sarifMessage{Text: r.LongDescription} + desc.HelpURI = r.HelpURI desc.Help = sarifHelpFor(r) desc.Properties = sarifProperties{Tags: []string{r.Category}} } @@ -192,37 +192,18 @@ func (f SARIFFormat) WriteIssues(w io.Writer, issues []linter.Issue) error { return nil } -// sarifHelpFor returns the rule's rationale and reference links as SARIF help, the text a viewer -// shows when a reader asks why an alert was raised. It is the zero value, and so omitted, for a rule -// that documents neither. +// sarifHelpFor returns a link to the rule's documentation as SARIF help, the text a viewer shows +// when a reader asks why an alert was raised. GitHub Code Scanning renders the Markdown form on the +// alert but does not surface helpUri, so the link has to be in the message to be reachable. It is +// the zero value, and so omitted, for a rule with nowhere to link to. func sarifHelpFor(r SARIFRule) sarifMultiformatMessage { - if len(r.References) == 0 { - // Prose alone renders the same either way, so there is no Markdown rendering to add. - return sarifMultiformatMessage{Text: r.LongDescription} - } - var plain, md strings.Builder - plain.WriteString("References:") - md.WriteString("**References**\n") - for _, ref := range r.References { - plain.WriteString("\n- " + ref) - // Angle brackets make the URL a link even where it contains Markdown punctuation. - md.WriteString("\n- <" + ref + ">") + if r.HelpURI == "" { + return sarifMultiformatMessage{} } return sarifMultiformatMessage{ - Text: joinParagraphs(r.LongDescription, plain.String()), - Markdown: joinParagraphs(r.LongDescription, md.String()), - } -} - -// joinParagraphs joins the non-empty sections with a blank line between them. -func joinParagraphs(sections ...string) string { - kept := make([]string, 0, len(sections)) - for _, s := range sections { - if s != "" { - kept = append(kept, s) - } + Text: "Documentation: " + r.HelpURI, + Markdown: "[Rule documentation](" + r.HelpURI + ")", } - return strings.Join(kept, "\n\n") } // artifactURIFor returns the SARIF location of the file at path, which must be a URI rather than a diff --git a/cmd/decolint/format/sarif_test.go b/cmd/decolint/format/sarif_test.go index b8b9477..ab46781 100644 --- a/cmd/decolint/format/sarif_test.go +++ b/cmd/decolint/format/sarif_test.go @@ -14,11 +14,10 @@ func testSARIFFormat() SARIFFormat { Version: "1.2.3", Rules: []SARIFRule{ { - ID: "no-image-latest", - Description: "images should be pinned to a specific version", - LongDescription: "An unpinned image can change between builds.", - References: []string{"https://containers.dev/implementors/json_reference/"}, - Category: "reproducibility", + ID: "no-image-latest", + Description: "images should be pinned to a specific version", + Category: "reproducibility", + HelpURI: "https://example.invalid/rules/no-image-latest/", }, }, } @@ -35,9 +34,9 @@ func TestSARIFWriteIssues(t *testing.T) { want := `{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","version":"2.1.0","runs":[` + `{"tool":{"driver":{"name":"decolint","version":"1.2.3","informationUri":"https://github.com/bare-devcontainer/decolint","rules":[` + `{"id":"no-image-latest","shortDescription":{"text":"images should be pinned to a specific version"},` + - `"fullDescription":{"text":"An unpinned image can change between builds."},` + - `"help":{"text":"An unpinned image can change between builds.\n\nReferences:\n- https://containers.dev/implementors/json_reference/",` + - `"markdown":"An unpinned image can change between builds.\n\n**References**\n\n- "},` + + `"helpUri":"https://example.invalid/rules/no-image-latest/",` + + `"help":{"text":"Documentation: https://example.invalid/rules/no-image-latest/",` + + `"markdown":"[Rule documentation](https://example.invalid/rules/no-image-latest/)"},` + `"properties":{"tags":["reproducibility"]}},` + `{"id":"some-error-rule"}]}},"results":[` + `{"ruleId":"no-image-latest","ruleIndex":0,"level":"warning","message":{"text":"image \"ubuntu:latest\" uses the \"latest\" tag; pin a specific version"},` + @@ -130,8 +129,8 @@ func TestSARIFWriteIssues_Empty(t *testing.T) { } } -// TestSARIFWriteIssues_RuleHelp checks how a rule's rationale and references become the descriptor's -// fullDescription and help, including that a rule documenting neither carries neither field. +// TestSARIFWriteIssues_RuleHelp checks how a rule's documentation address becomes the descriptor's +// helpUri and help, including that a rule with nowhere to link to carries neither field. func TestSARIFWriteIssues_RuleHelp(t *testing.T) { t.Parallel() @@ -142,32 +141,19 @@ func TestSARIFWriteIssues_RuleHelp(t *testing.T) { wantOmitted []string }{ { - name: "rationale and references", - rule: SARIFRule{ - ID: "some-error-rule", - LongDescription: "Why it matters.", - References: []string{"https://example.invalid/a", "https://example.invalid/b"}, - }, - want: []string{ - `"fullDescription":{"text":"Why it matters."}`, - `"help":{"text":"Why it matters.\n\nReferences:\n- https://example.invalid/a\n- https://example.invalid/b",` + - `"markdown":"Why it matters.\n\n**References**\n\n- \n- "}`, - }, - }, - { - name: "rationale only", - rule: SARIFRule{ID: "some-error-rule", LongDescription: "Why it matters."}, + name: "documented rule", + rule: SARIFRule{ID: "some-error-rule", HelpURI: "https://example.invalid/rules/some-error-rule/"}, want: []string{ - `"fullDescription":{"text":"Why it matters."}`, - `"help":{"text":"Why it matters."}`, + `"helpUri":"https://example.invalid/rules/some-error-rule/"`, + `"help":{"text":"Documentation: https://example.invalid/rules/some-error-rule/",` + + `"markdown":"[Rule documentation](https://example.invalid/rules/some-error-rule/)"}`, }, - wantOmitted: []string{`"markdown"`}, }, { - name: "neither", + name: "no documentation address", rule: SARIFRule{ID: "some-error-rule", Description: "short only"}, want: []string{`"shortDescription":{"text":"short only"}`}, - wantOmitted: []string{`"fullDescription"`, `"help"`}, + wantOmitted: []string{`"helpUri"`, `"help"`}, }, } for _, tt := range tests { diff --git a/cmd/decolint/format_test.go b/cmd/decolint/format_test.go index b246956..90b9e3e 100644 --- a/cmd/decolint/format_test.go +++ b/cmd/decolint/format_test.go @@ -31,11 +31,10 @@ func TestSARIFRules(t *testing.T) { } want := format.SARIFRule{ - ID: reg.Rule.ID, - Description: reg.Rule.Description, - LongDescription: reg.Rule.LongDescription, - References: reg.Rule.References, - Category: reg.Rule.Category.String(), + ID: reg.Rule.ID, + Description: reg.Rule.Description, + Category: reg.Rule.Category.String(), + HelpURI: rules.DocsURL(reg.Rule.ID), } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("sarifRules() entry mismatch (-want +got):\n%s", diff) diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index 31050a2..74da634 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -177,9 +177,10 @@ func platformNames(platforms []linter.Platform, sep string) string { return strings.Join(names, sep) } -// explainRule writes everything decolint documents about the rule with the given ID to output: what -// it checks, why, and the references that justify it. It returns an error if no built-in rule has -// that ID. The rule's severity is left to [listRules], which reports it for every rule at once. +// explainRule writes what decolint knows about the rule with the given ID to output: what it +// checks, the platforms it applies to, and where it is documented in full. It returns an error if +// no built-in rule has that ID. The rule's severity is left to [listRules], which reports it for +// every rule at once. func explainRule(output io.Writer, id string) error { builtin := rules.Builtin() i := slices.IndexFunc(builtin, func(reg rules.Registration) bool { return reg.Rule.ID == id }) @@ -191,14 +192,7 @@ func explainRule(output io.Writer, id string) error { sections := []string{ fmt.Sprintf("%s (%s)\nPlatform: %s", rule.ID, rule.Category, platformNames(rule.Platforms, ", ")), rule.Description, - rule.LongDescription, - } - if len(rule.References) > 0 { - refs := "References:" - for _, ref := range rule.References { - refs += "\n " + ref - } - sections = append(sections, refs) + "Documentation: " + rules.DocsURL(rule.ID), } if _, err := fmt.Fprintln(output, strings.Join(sections, "\n\n")); err != nil { return fmt.Errorf("write rule documentation: %w", err) diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index c03da01..2411367 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -453,8 +453,7 @@ func TestRun_Flags(t *testing.T) { // The rule targets Codespaces, so the platform it is scoped to is named rather than "(all)". "codespaces", rule.Description, - rule.LongDescription, - rule.References[0], + rules.DocsURL(rule.ID), } { if !strings.Contains(out, want) { t.Errorf("stdout = %q, want it to contain %q", out, want) diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index 5d81a61..9ea8263 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -67,7 +67,7 @@ func parseOptions(args []string, output io.Writer) (Options, error) { fs.BoolVar(&opts.Merge, "merge", false, "lint the merged (effective) configuration, including referenced Features and base image metadata; overrides the config file's \"merge\" member") fs.BoolVar(&opts.Version, "version", false, "print version information and exit") fs.BoolVar(&opts.ListRules, "rules", false, "print the built-in rules as a Markdown table (category, target platforms, current severity), then exit") - fs.StringVar(&opts.Explain, "explain", "", "print what the rule with this ID checks, why, and where it is documented, then exit") + fs.StringVar(&opts.Explain, "explain", "", "print what the rule with this ID checks and where it is documented, then exit") fs.BoolVar(&opts.Init, "init", false, "write a new .decolint.jsonc config file listing every rule at its default severity, then exit") fs.Usage = func() { _ = usage(fs) } if err := fs.Parse(args); err != nil { diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..e4745ec --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,27 @@ +title: decolint +description: A linter for Dev Container configuration files. +url: https://bare-devcontainer.github.io +baseurl: /decolint +theme: minima +permalink: pretty + +plugins: + - jekyll-relative-links + +header_pages: + - getting-started.md + - rules/index.md + +minima: + skin: auto + +defaults: + - scope: + path: "" + values: + layout: page + +exclude: + - Gemfile + - Gemfile.lock + - vendor diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..6cb104e --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,156 @@ +--- +title: Getting started +--- + +This page takes you from an empty shell to decolint running in CI. Every +setting it mentions is documented in full in the +[README](https://github.com/bare-devcontainer/decolint#readme). + +## Install + +A prebuilt binary is the quickest way to start. Download one from the +[releases page](https://github.com/bare-devcontainer/decolint/releases); the +artifacts are signed and carry build provenance. + +To run it as a container instead: + +```console +docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint +``` + +Or build it from source, which needs Go 1.26 or newer: + +```console +GOEXPERIMENT=jsonv2 go install github.com/bare-devcontainer/decolint/cmd/decolint@latest +``` + +`GOEXPERIMENT=jsonv2` is required because decolint uses the still experimental +`encoding/json/v2` package. + +## Run it + +Point decolint at a directory, or at nothing to lint the current one: + +```console +$ decolint +.devcontainer/devcontainer.json:3:12: error: "build" is missing "dockerfile" (missing-build-dockerfile) +Found 1 error and 0 warnings. +``` + +decolint works out what each directory is from its layout and lints the +configuration files it finds: + +- a **dev container definition** — `.devcontainer/devcontainer.json`, + `.devcontainer.json`, or `.devcontainer//devcontainer.json` +- a **Feature** — `devcontainer-feature.json` +- a **Template** — `devcontainer-template.json`, plus the dev container + configuration the template ships + +It exits `0` when nothing was reported at `error` severity, `1` when something +was, and `2` if it could not do its job — a file that does not parse, say. + +## Choosing what to report + +Every rule belongs to one of four categories, and only `correctness` is +enabled out of the box: + +| Category | Default | Reports | +| --- | --- | --- | +| `correctness` | `error` | configuration that is invalid or does not behave as written | +| `security` | `off` | container runtime privileges and hardening | +| `reproducibility` | `off` | unpinned versions or digests that let the environment drift | +| `style` | `off` | discouraged or legacy configuration that still works | + +Turn the others on in a config file. `decolint -init` writes one listing every +rule, ready to edit: + +```jsonc +// .decolint.jsonc +{ + "categories": { + "security": "error", + "reproducibility": "warn" + }, + "rules": { + "pin-image-digest": "off" + } +} +``` + +`rules` overrides individual rules and wins over their category. Both accept +`error`, `warn` and `off`. The [rule reference](rules/) lists what each rule +checks and why. + +Some rules apply only to a particular platform — Codespaces ignores `bind` +mounts, for instance, which is worth reporting only if you use Codespaces. +Those rules stay off until you name the platform: + +```console +decolint -platform=vscode,codespaces +``` + +## Lint what actually runs + +A container's configuration is not only what its own file says: the base image +and every Feature it references contribute their own, and the tooling merges +them all. A Feature that sets `privileged: true` is invisible to a linter that +reads only `devcontainer.json`. + +`-merge` resolves the base image and every referenced Feature, applies the +specification's merge logic, and lints the result: + +```console +decolint -merge +``` + +This one reaches the network, so it belongs in CI rather than in a pre-commit +hook. A Feature or image that cannot be fetched is an error. + +## In CI + +On GitHub Actions, the `github` format turns findings into inline annotations +on the pull request diff: + +```yaml +- run: decolint -format=github -deny-warnings . +``` + +`-deny-warnings` makes `warn` findings fail the job too; without it only +`error` findings do. + +To collect findings as alerts in the repository's Security tab instead, write +a SARIF log and upload it: + +```yaml +- run: decolint -merge -format=sarif . > decolint.sarif + continue-on-error: true # decolint exits 1 on findings; the upload must still run +- uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: decolint.sarif +``` + +Each alert links back to the rule's page here, so whoever picks it up gets the +reasoning without leaving the alert. + +## Silencing a finding + +When a rule is right in general but wrong for one line, suppress it in the +configuration file itself rather than turning the rule off everywhere: + +```jsonc +{ + // decolint-ignore-line no-image-latest + "image": "mcr.microsoft.com/devcontainers/base:latest" +} +``` + +`decolint-ignore-line` covers the line it is on, `decolint-ignore-next-line` +the line after it, and `decolint-ignore-file` the whole file. Naming rule IDs +after the directive limits it to those rules; naming none suppresses +everything on that line. + +## Next steps + +- [Rules](rules/) — what each rule checks, why, and what it accepts. +- [README](https://github.com/bare-devcontainer/decolint#readme) — the full + reference for flags, the config file, output formats and merging. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d8d81fc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,38 @@ +--- +title: decolint +--- + +decolint is a linter for Dev Container configuration files. It reads +`devcontainer.json`, `devcontainer-feature.json` and +`devcontainer-template.json`, checks them against the +[Dev Container specification](https://containers.dev/), and reports what is +invalid, unsafe, or unreproducible — before anyone waits for a container to +build. + +```console +$ decolint . +.devcontainer/devcontainer.json:4:3: error: devcontainer.json must define only one of "image", "build", or "dockerComposeFile" (conflicting-container-def) +Found 1 error and 0 warnings. +``` + +## Why decolint + +- **It reads the same files the tooling does.** Comments and trailing commas + are parsed as the specification requires, and findings point at the exact + line and column of the offending value. +- **It runs without Docker.** No image is pulled and no container is built, so + it fits in a pre-commit hook or the first job of a pipeline. Rules that + resolve Features against a registry are the only ones that reach the network. +- **It reports what your project cares about.** Rules are grouped into + [categories](getting-started.md#choosing-what-to-report) — correctness, + security, reproducibility and style — and only correctness is on by default. +- **It fits into code scanning.** The SARIF output uploads to GitHub Code + Scanning, so findings land as annotations on the pull request that + introduced them. + +## Next steps + +- [Getting started](getting-started.md) — install decolint, run it, and wire it + into CI. +- [Rules](rules/) — every rule, with the reasoning behind it and examples of + the configuration it accepts and rejects. diff --git a/docs/rules/conflicting-container-def.md b/docs/rules/conflicting-container-def.md new file mode 100644 index 0000000..0aa532d --- /dev/null +++ b/docs/rules/conflicting-container-def.md @@ -0,0 +1,46 @@ +--- +title: conflicting-container-def +category: correctness +platforms: [] +file_types: [devcontainer] +description: >- + disallow a devcontainer.json that defines more than one of "image", + "build", or "dockerComposeFile" +--- + +*{{ page.description }}* + +## Why + +The specification defines three mutually exclusive ways to create the container: from an image, from a +Dockerfile, or from a Docker Compose project. Which one wins when several are set is unspecified, so the +container that gets built depends on the tool rather than on the configuration. Keep the variant the +project actually uses and remove the others. + +## Bad + +```jsonc +{ + "name": "my project", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "build": { + "dockerfile": "Dockerfile" + } +} +``` + +## Good + +```jsonc +{ + "name": "my project", + "build": { + "dockerfile": "Dockerfile" + } +} +``` + +## References + +- +- diff --git a/docs/rules/feature-install-script-not-executable.md b/docs/rules/feature-install-script-not-executable.md new file mode 100644 index 0000000..50ab52a --- /dev/null +++ b/docs/rules/feature-install-script-not-executable.md @@ -0,0 +1,40 @@ +--- +title: feature-install-script-not-executable +category: correctness +platforms: [] +file_types: [feature] +description: >- + disallow a Feature's `install.sh` that lacks executable permission bits +example_verify: false +--- + +*{{ page.description }}* + +## Why + +The specification has the installing tool invoke "install.sh" directly rather than through a shell, so +that the script's own shebang selects the interpreter. That requires the execute bit: without it the +Feature fails to install when a container is built. Run "chmod +x install.sh" and commit the mode change. + +## Bad + +```console +$ ls -l install.sh +-rw-r--r-- 1 user user 214 Jan 1 00:00 install.sh +``` + +## Good + +```console +$ chmod +x install.sh +$ ls -l install.sh +-rwxr-xr-x 1 user user 214 Jan 1 00:00 install.sh +``` + +Git records the executable bit, so committing the mode change is what makes +the fix stick. On Windows, where the filesystem has no executable bit, set it +in the index directly: `git update-index --chmod=+x install.sh`. + +## References + +- diff --git a/docs/rules/id-dir-mismatch.md b/docs/rules/id-dir-mismatch.md new file mode 100644 index 0000000..34b2233 --- /dev/null +++ b/docs/rules/id-dir-mismatch.md @@ -0,0 +1,46 @@ +--- +title: id-dir-mismatch +category: correctness +platforms: [] +file_types: [feature, template] +description: >- + disallow a Feature's or Template's "id" that does not match the name of + its containing directory +example_dir: node +--- + +*{{ page.description }}* + +## Why + +Both specifications require the "id" to match the name of the directory holding the metadata file, since +that directory name is what packaging and distribution address the artifact by. When the two disagree the +published reference does not resolve to what the directory contains; rename the directory or the "id" so +they agree. + +## Bad + +```jsonc +// src/node/devcontainer-feature.json +{ + "id": "nodejs", + "version": "1.0.0", + "name": "Node.js" +} +``` + +## Good + +```jsonc +// src/node/devcontainer-feature.json +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +``` + +## References + +- +- diff --git a/docs/rules/index.md b/docs/rules/index.md new file mode 100644 index 0000000..cf311ae --- /dev/null +++ b/docs/rules/index.md @@ -0,0 +1,63 @@ +--- +title: Rules +--- + +decolint ships 27 rules. Each belongs to one category, which sets its severity +unless a [config file](../getting-started.md#choosing-what-to-report) overrides +it; only `correctness` is enabled out of the box. A rule that names a platform +runs only when that platform is selected with `-platform`. + +## correctness + +The configuration is invalid or does not behave as written. These run by default, at `error`. + +| Rule | Platform | Checks | +| --- | --- | --- | +| [`conflicting-container-def`](conflicting-container-def.md) | (all) | disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile" | +| [`feature-install-script-not-executable`](feature-install-script-not-executable.md) | (all) | disallow a Feature's `install.sh` that lacks executable permission bits | +| [`id-dir-mismatch`](id-dir-mismatch.md) | (all) | disallow a Feature's or Template's "id" that does not match the name of its containing directory | +| [`invalid-semver`](invalid-semver.md) | (all) | disallow a Feature's or Template's "version" that is not a valid semantic version | +| [`missing-build-dockerfile`](missing-build-dockerfile.md) | (all) | disallow a devcontainer.json "build" object that is missing "dockerfile" | +| [`missing-compose-service`](missing-compose-service.md) | (all) | disallow a devcontainer.json that sets "dockerComposeFile" without "service" | +| [`missing-container-def`](missing-container-def.md) | (all) | disallow a devcontainer.json that defines none of "image", "build", or "dockerComposeFile" | +| [`missing-feature-install-script`](missing-feature-install-script.md) | (all) | disallow a Feature directory without the required `install.sh` install script | +| [`missing-required-props`](missing-required-props.md) | (all) | disallow a Feature's or Template's metadata that is missing a required property ("id", "version", or "name") | +| [`missing-workspace-mount-folder`](missing-workspace-mount-folder.md) | (all) | disallow a devcontainer.json using "image" or "build" that sets only one of "workspaceMount" or "workspaceFolder" | +| [`no-bind-mount`](no-bind-mount.md) | `codespaces` | disallow "bind" type entries in "mounts", which GitHub Codespaces silently ignores except for the Docker socket | +| [`no-host-port-format`](no-host-port-format.md) | `codespaces` | disallow "host:port" entries in "forwardPorts" and "portsAttributes", which GitHub Codespaces does not support | +| [`undefined-template-option`](undefined-template-option.md) | (all) | disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json | + +## security + +Container runtime privileges and hardening. + +| Rule | Platform | Checks | +| --- | --- | --- | +| [`no-cap-add-all`](no-cap-add-all.md) | (all) | disallow granting all Linux capabilities via an "ALL" entry in the "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's "runArgs" | +| [`no-docker-socket-mount`](no-docker-socket-mount.md) | (all) | disallow bind-mounting the host's Docker socket via a devcontainer.json's "mounts" or "runArgs", which grants the container root-equivalent control over the host | +| [`no-privileged-container`](no-privileged-container.md) | (all) | disallow running the container in privileged mode via the "privileged" property or a "--privileged" entry in "runArgs" | +| [`no-seccomp-override`](no-seccomp-override.md) | (all) | disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs" | +| [`no-seccomp-unconfined`](no-seccomp-unconfined.md) | (all) | disallow disabling seccomp confinement via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" entry in a devcontainer.json's "runArgs" | +| [`require-cap-drop-all`](require-cap-drop-all.md) | (all) | require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", dropping every Linux capability | +| [`require-no-new-privileges`](require-no-new-privileges.md) | (all) | require "no-new-privileges" to be set via a devcontainer.json's "securityOpt" property, or a "--security-opt no-new-privileges..." entry in "runArgs" | +| [`require-non-root`](require-non-root.md) | (all) | require "remoteUser" or, if unset, "containerUser" to be set to a non-root user | + +## reproducibility + +Unpinned versions or digests that let the environment drift between builds. + +| Rule | Platform | Checks | +| --- | --- | --- | +| [`no-image-latest`](no-image-latest.md) | (all) | disallow container images without an explicit tag or with the "latest" tag | +| [`pin-extension-version`](pin-extension-version.md) | `vscode`, `codespaces` | disallow a "customizations.vscode.extensions" entry without an explicit pinned version | +| [`pin-feature-version`](pin-feature-version.md) | (all) | disallow a Feature reference without an explicit version or with the "latest" version | +| [`pin-image-digest`](pin-image-digest.md) | (all) | disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...") | + +## style + +Discouraged or legacy configuration that still works. + +| Rule | Platform | Checks | +| --- | --- | --- | +| [`no-app-port`](no-app-port.md) | (all) | disallow the legacy "appPort" property in favor of "forwardPorts" | +| [`unused-template-option`](unused-template-option.md) | (all) | disallow a Template option that no file in the Template references | diff --git a/docs/rules/invalid-semver.md b/docs/rules/invalid-semver.md new file mode 100644 index 0000000..7fede71 --- /dev/null +++ b/docs/rules/invalid-semver.md @@ -0,0 +1,42 @@ +--- +title: invalid-semver +category: correctness +platforms: [] +file_types: [feature, template] +description: >- + disallow a Feature's or Template's "version" that is not a valid semantic + version +--- + +*{{ page.description }}* + +## Why + +Publishing a Feature or Template pushes it under tags derived from the "version" components: the full +version, "major.minor", and "major", so consumers can pin as loosely or as tightly as they want. A value +that is not valid semver has no such components, leaving nothing to derive those tags from. + +## Bad + +```jsonc +{ + "id": "node", + "version": "1.0", + "name": "Node.js" +} +``` + +## Good + +```jsonc +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +``` + +## References + +- +- diff --git a/docs/rules/missing-build-dockerfile.md b/docs/rules/missing-build-dockerfile.md new file mode 100644 index 0000000..0e168f5 --- /dev/null +++ b/docs/rules/missing-build-dockerfile.md @@ -0,0 +1,44 @@ +--- +title: missing-build-dockerfile +category: correctness +platforms: [] +file_types: [devcontainer] +description: >- + disallow a devcontainer.json "build" object that is missing "dockerfile" +--- + +*{{ page.description }}* + +## Why + +"build.dockerfile" is the only required member of "build": it locates, relative to the devcontainer.json, +the Dockerfile the image is built from. The other members ("context", "args", "target", ...) only shape a +build that "dockerfile" defines, so without it there is nothing to build. + +## Bad + +```jsonc +{ + "name": "my project", + "build": { + "context": ".." + } +} +``` + +## Good + +```jsonc +{ + "name": "my project", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + } +} +``` + +## References + +- +- diff --git a/docs/rules/missing-compose-service.md b/docs/rules/missing-compose-service.md new file mode 100644 index 0000000..8acae4d --- /dev/null +++ b/docs/rules/missing-compose-service.md @@ -0,0 +1,43 @@ +--- +title: missing-compose-service +category: correctness +platforms: [] +file_types: [devcontainer] +description: >- + disallow a devcontainer.json that sets "dockerComposeFile" without + "service" +--- + +*{{ page.description }}* + +## Why + +A Compose project usually defines several services, so naming the Compose file does not say which +container the tooling should attach to. The specification requires "service" to name that main container: +it is the one lifecycle scripts run in and the one editors connect to. + +## Bad + +```jsonc +{ + "name": "my project", + "dockerComposeFile": "docker-compose.yml", + "workspaceFolder": "/workspace" +} +``` + +## Good + +```jsonc +{ + "name": "my project", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace" +} +``` + +## References + +- +- diff --git a/docs/rules/missing-container-def.md b/docs/rules/missing-container-def.md new file mode 100644 index 0000000..b6bee5e --- /dev/null +++ b/docs/rules/missing-container-def.md @@ -0,0 +1,41 @@ +--- +title: missing-container-def +category: correctness +platforms: [] +file_types: [devcontainer] +description: >- + disallow a devcontainer.json that defines none of "image", "build", or + "dockerComposeFile" +--- + +*{{ page.description }}* + +## Why + +Every dev container is created from exactly one of "image", "build", or "dockerComposeFile", and each of +the three is required in its own scenario. A configuration that sets none of them describes no container +at all, so no tool can create one from it. + +## Bad + +```jsonc +{ + "name": "my project", + "forwardPorts": [3000] +} +``` + +## Good + +```jsonc +{ + "name": "my project", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [3000] +} +``` + +## References + +- +- diff --git a/docs/rules/missing-feature-install-script.md b/docs/rules/missing-feature-install-script.md new file mode 100644 index 0000000..5410b0b --- /dev/null +++ b/docs/rules/missing-feature-install-script.md @@ -0,0 +1,41 @@ +--- +title: missing-feature-install-script +category: correctness +platforms: [] +file_types: [feature] +description: >- + disallow a Feature directory without the required `install.sh` install + script +example_verify: false +--- + +*{{ page.description }}* + +## Why + +A Feature is distributed as its metadata file plus the "install.sh" the tooling runs inside the container, +which is where the Feature does all of its work. A directory without one publishes a Feature that +installs nothing, and the omission only surfaces when someone builds a container with it. + +## Bad + +```text +src/node/ +└── devcontainer-feature.json +``` + +## Good + +```text +src/node/ +├── devcontainer-feature.json +└── install.sh +``` + +The name is fixed: the tooling runs `install.sh` and nothing else, so an +install script under any other name is never executed. + +## References + +- +- diff --git a/docs/rules/missing-required-props.md b/docs/rules/missing-required-props.md new file mode 100644 index 0000000..92da134 --- /dev/null +++ b/docs/rules/missing-required-props.md @@ -0,0 +1,41 @@ +--- +title: missing-required-props +category: correctness +platforms: [] +file_types: [feature, template] +description: >- + disallow a Feature's or Template's metadata that is missing a required + property ("id", "version", or "name") +--- + +*{{ page.description }}* + +## Why + +"id", "version", and "name" are the only properties either specification requires: the "id" addresses the +artifact, the "version" is what consumers pin to, and the "name" is what a user recognizes it by in a +list. Metadata missing any of them cannot be published as a usable Feature or Template. + +## Bad + +```jsonc +{ + "id": "node", + "version": "1.0.0" +} +``` + +## Good + +```jsonc +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +``` + +## References + +- +- diff --git a/docs/rules/missing-workspace-mount-folder.md b/docs/rules/missing-workspace-mount-folder.md new file mode 100644 index 0000000..237176f --- /dev/null +++ b/docs/rules/missing-workspace-mount-folder.md @@ -0,0 +1,42 @@ +--- +title: missing-workspace-mount-folder +category: correctness +platforms: [] +file_types: [devcontainer] +description: >- + disallow a devcontainer.json using "image" or "build" that sets only one + of "workspaceMount" or "workspaceFolder" +--- + +*{{ page.description }}* + +## Why + +The two properties describe opposite ends of the same override: "workspaceMount" says where the source +code is mounted, "workspaceFolder" says which path inside the container the tooling opens. The reference +documents each as requiring the other, because setting one alone either mounts the source somewhere +nothing opens, or opens a path nothing is mounted at. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "workspaceMount": "source=${localWorkspaceFolder},target=/srv/app,type=bind" +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "workspaceMount": "source=${localWorkspaceFolder},target=/srv/app,type=bind", + "workspaceFolder": "/srv/app" +} +``` + +## References + +- +- diff --git a/docs/rules/no-app-port.md b/docs/rules/no-app-port.md new file mode 100644 index 0000000..b795048 --- /dev/null +++ b/docs/rules/no-app-port.md @@ -0,0 +1,40 @@ +--- +title: no-app-port +category: style +platforms: [] +file_types: [devcontainer] +description: >- + disallow the legacy "appPort" property in favor of "forwardPorts" +--- + +*{{ page.description }}* + +## Why + +"appPort" publishes the port the way Docker does: it is fixed when the container is created, and the +application has to listen on all interfaces rather than just "localhost" to be reachable. A forwarded +port instead looks like "localhost" to the application and can be changed without recreating the +container, which is why the reference recommends "forwardPorts" in most cases. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "appPort": [3000] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [3000] +} +``` + +## References + +- +- diff --git a/docs/rules/no-bind-mount.md b/docs/rules/no-bind-mount.md new file mode 100644 index 0000000..b239451 --- /dev/null +++ b/docs/rules/no-bind-mount.md @@ -0,0 +1,53 @@ +--- +title: no-bind-mount +category: correctness +platforms: [codespaces] +file_types: [devcontainer] +description: >- + disallow "bind" type entries in "mounts", which GitHub Codespaces silently + ignores except for the Docker socket +--- + +*{{ page.description }}* + +## Why + +A codespace runs on a machine in the cloud, where the host path a bind mount points at does not exist, so +Codespaces documents that it ignores "bind" mounts apart from the Docker socket. The mount is dropped +without an error and the container starts missing the data it expects. Volume mounts are honored, so use +"type=volume" for anything that only has to persist across rebuilds. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "mounts": [ + { + "source": "${localWorkspaceFolder}/.cache", + "target": "/home/vscode/.cache", + "type": "bind" + } + ] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "mounts": [ + { + "source": "devcontainer-cache", + "target": "/home/vscode/.cache", + "type": "volume" + } + ] +} +``` + +## References + +- +- diff --git a/docs/rules/no-cap-add-all.md b/docs/rules/no-cap-add-all.md new file mode 100644 index 0000000..822e378 --- /dev/null +++ b/docs/rules/no-cap-add-all.md @@ -0,0 +1,42 @@ +--- +title: no-cap-add-all +category: security +platforms: [] +file_types: [devcontainer, feature] +description: >- + disallow granting all Linux capabilities via an "ALL" entry in the + "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's + "runArgs" +--- + +*{{ page.description }}* + +## Why + +Linux capabilities split root's powers into units a container can be granted individually, and the runtime +withholds the dangerous ones by default. "ALL" hands them all over, including capabilities such as +"SYS_ADMIN" and "SYS_MODULE" that let a process reconfigure the host kernel and escape the container. +"capAdd" exists to name the one or two a workload actually needs, e.g. "SYS_PTRACE" for a debugger. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["ALL"] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +``` + +## References + +- +- diff --git a/docs/rules/no-docker-socket-mount.md b/docs/rules/no-docker-socket-mount.md new file mode 100644 index 0000000..d07d224 --- /dev/null +++ b/docs/rules/no-docker-socket-mount.md @@ -0,0 +1,51 @@ +--- +title: no-docker-socket-mount +category: security +platforms: [] +file_types: [devcontainer] +description: >- + disallow bind-mounting the host's Docker socket via a devcontainer.json's + "mounts" or "runArgs", which grants the container root-equivalent control + over the host +--- + +*{{ page.description }}* + +## Why + +The Docker socket is the daemon's full API, and the daemon runs as root on the host. Anything that can +reach the socket can start a container that mounts the host's filesystem, so mounting it into the dev +container hands root-equivalent control of the host to every process inside — including code the +project's own build fetches. When the container genuinely needs Docker, a Docker-in-Docker Feature or a +rootless daemon keeps that access inside the container. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "mounts": [ + { + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "type": "bind" + } + ] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2.13.0": {} + } +} +``` + +## References + +- +- diff --git a/docs/rules/no-host-port-format.md b/docs/rules/no-host-port-format.md new file mode 100644 index 0000000..4709b08 --- /dev/null +++ b/docs/rules/no-host-port-format.md @@ -0,0 +1,41 @@ +--- +title: no-host-port-format +category: correctness +platforms: [codespaces] +file_types: [devcontainer] +description: >- + disallow "host:port" entries in "forwardPorts" and "portsAttributes", + which GitHub Codespaces does not support +--- + +*{{ page.description }}* + +## Why + +The "host:port" form forwards a port from another container in a Docker Compose project (e.g. "db:5432") +rather than from the primary one. Codespaces documents that it does not support that variation of either +property, so the entry is ignored there and the port is not forwarded. A bare port number, which refers +to the primary container, works everywhere. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": ["db:5432"] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [5432] +} +``` + +## References + +- +- diff --git a/docs/rules/no-image-latest.md b/docs/rules/no-image-latest.md new file mode 100644 index 0000000..57eb91e --- /dev/null +++ b/docs/rules/no-image-latest.md @@ -0,0 +1,37 @@ +--- +title: no-image-latest +category: reproducibility +platforms: [] +file_types: [devcontainer] +description: >- + disallow container images without an explicit tag or with the "latest" tag +--- + +*{{ page.description }}* + +## Why + +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. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:latest" +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" +} +``` + +## References + +- diff --git a/docs/rules/no-privileged-container.md b/docs/rules/no-privileged-container.md new file mode 100644 index 0000000..d5b0732 --- /dev/null +++ b/docs/rules/no-privileged-container.md @@ -0,0 +1,47 @@ +--- +title: no-privileged-container +category: security +platforms: [] +file_types: [devcontainer, feature] +description: >- + disallow running the container in privileged mode via the "privileged" + property or a "--privileged" entry in "runArgs" +--- + +*{{ page.description }}* + +## Why + +A privileged container gets every Linux capability, unconfined seccomp and LSM profiles, and access to all +host devices. That removes essentially every boundary between the container and the host, so any code +running in it — including a compromised dependency pulled in by the project's own build — can take over +the machine. Docker-in-Docker is the usual reason it is set; a Feature that provides it, or the specific +capabilities and devices the workload needs, is a far narrower grant. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "privileged": true +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +``` + +The good example grants only the capability a debugger needs. Reach for the +narrowest grant that works: `capAdd` for a capability, `--device` in `runArgs` +for a device, and the docker-in-docker Feature rather than privileged mode for +nested containers. + +## References + +- +- diff --git a/docs/rules/no-seccomp-override.md b/docs/rules/no-seccomp-override.md new file mode 100644 index 0000000..f58bf2e --- /dev/null +++ b/docs/rules/no-seccomp-override.md @@ -0,0 +1,46 @@ +--- +title: no-seccomp-override +category: security +platforms: [] +file_types: [devcontainer, feature] +description: >- + disallow overriding the container runtime's default seccomp profile via a + devcontainer.json's or Feature's "securityOpt" property, or a + "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs" +--- + +*{{ page.description }}* + +## Why + +The runtime's default seccomp profile blocks the syscalls containers do not need, several of which have +featured in container escapes. Pointing "seccomp" at a profile of your own replaces that default +wholesale, and a hand-written profile is rarely reviewed as carefully or updated as the kernel gains new +syscalls. Keep the default unless the workload provably needs more, and review the replacement if it +does. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "securityOpt": ["seccomp=./seccomp.json"] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +``` + +Leaving `securityOpt` unset keeps the runtime's default seccomp profile, +which already allows what a development container normally does. + +## References + +- +- diff --git a/docs/rules/no-seccomp-unconfined.md b/docs/rules/no-seccomp-unconfined.md new file mode 100644 index 0000000..1bc3acb --- /dev/null +++ b/docs/rules/no-seccomp-unconfined.md @@ -0,0 +1,42 @@ +--- +title: no-seccomp-unconfined +category: security +platforms: [] +file_types: [devcontainer, feature] +description: >- + disallow disabling seccomp confinement via a devcontainer.json's or + Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" + entry in a devcontainer.json's "runArgs" +--- + +*{{ page.description }}* + +## Why + +"seccomp=unconfined" turns off syscall filtering entirely, exposing the whole kernel API — including the +calls the default profile blocks precisely because they have been used to break out of containers. The +setting is most often copied from debugger instructions, where granting the "SYS_PTRACE" capability is +enough on current runtimes. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "securityOpt": ["seccomp=unconfined"] +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +``` + +## References + +- +- diff --git a/docs/rules/pin-extension-version.md b/docs/rules/pin-extension-version.md new file mode 100644 index 0000000..c87e4d1 --- /dev/null +++ b/docs/rules/pin-extension-version.md @@ -0,0 +1,49 @@ +--- +title: pin-extension-version +category: reproducibility +platforms: [vscode, codespaces] +file_types: [devcontainer] +description: >- + disallow a "customizations.vscode.extensions" entry without an explicit + pinned version +--- + +*{{ page.description }}* + +## Why + +An extension ID on its own installs whatever the marketplace publishes at the moment the container is +created, so two developers on the same devcontainer.json can end up with different formatters, linters, or +language server versions — and an extension update can change the environment without any commit. +Appending a version ("publisher.name@1.2.3") makes the editor tooling as pinned as the rest of the image. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": ["golang.go"] + } + } +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": ["golang.go@0.50.0"] + } + } +} +``` + +## References + +- +- diff --git a/docs/rules/pin-feature-version.md b/docs/rules/pin-feature-version.md new file mode 100644 index 0000000..01c3bde --- /dev/null +++ b/docs/rules/pin-feature-version.md @@ -0,0 +1,45 @@ +--- +title: pin-feature-version +category: reproducibility +platforms: [] +file_types: [devcontainer] +description: >- + disallow a Feature reference without an explicit version or with the + "latest" version +--- + +*{{ page.description }}* + +## Why + +A Feature reference with no version resolves to "latest", so the container installs whatever the Feature's +author published most recently — the tooling it sets up can change under the project without the +devcontainer.json changing at all. Features are published under their full version as well as +"major.minor" and "major" tags, so a reference can be pinned as tightly as the project wants. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/go": {} + } +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/go:1.3.2": {} + } +} +``` + +## References + +- +- diff --git a/docs/rules/pin-image-digest.md b/docs/rules/pin-image-digest.md new file mode 100644 index 0000000..2e57075 --- /dev/null +++ b/docs/rules/pin-image-digest.md @@ -0,0 +1,39 @@ +--- +title: pin-image-digest +category: reproducibility +platforms: [] +file_types: [devcontainer] +description: >- + disallow an "image" property that does not pin the image by content digest + (e.g. "image@sha256:...") +--- + +*{{ page.description }}* + +## Why + +A tag is a mutable pointer: the publisher can move even a fully specified one to different bits, and a +registry can serve a different image for the same tag on a different day. A digest names the content +itself, so "image@sha256:..." always resolves to the exact image the project was tested with, and the +client verifies what it pulled against it. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:2a1d1e1a4b0c3f8e5c8a1e0a6d3b7c9f4e2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b" +} +``` + +## References + +- +- diff --git a/docs/rules/require-cap-drop-all.md b/docs/rules/require-cap-drop-all.md new file mode 100644 index 0000000..89742a8 --- /dev/null +++ b/docs/rules/require-cap-drop-all.md @@ -0,0 +1,46 @@ +--- +title: require-cap-drop-all +category: security +platforms: [] +file_types: [devcontainer] +description: >- + require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", + dropping every Linux capability +--- + +*{{ page.description }}* + +## Why + +Container runtimes grant a default set of capabilities that a dev container almost never uses: raw network +access, changing file ownership, or binding privileged ports. Dropping all of them and adding back only +what the workload needs ("capAdd") means a process that is compromised inherits no privilege the project +never asked for. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "runArgs": ["--cap-drop=ALL"], + "capAdd": ["CHOWN", "SETUID", "SETGID"] +} +``` + +`runArgs` is the only place this can be expressed: devcontainer.json has a +`capAdd` property but no `capDrop` one, so dropping capabilities means passing +the flag to the container runtime. Add back through `capAdd` whatever the +workload actually needs. + +## References + +- +- diff --git a/docs/rules/require-no-new-privileges.md b/docs/rules/require-no-new-privileges.md new file mode 100644 index 0000000..720583c --- /dev/null +++ b/docs/rules/require-no-new-privileges.md @@ -0,0 +1,41 @@ +--- +title: require-no-new-privileges +category: security +platforms: [] +file_types: [devcontainer] +description: >- + require "no-new-privileges" to be set via a devcontainer.json's + "securityOpt" property, or a "--security-opt no-new-privileges..." entry + in "runArgs" +--- + +*{{ page.description }}* + +## Why + +Without this option a process in the container can still gain privileges it was not started with, by +executing a setuid binary — which undercuts the point of running as a non-root user. Setting it raises the +kernel's "no_new_privs" bit, which every child process inherits and none can clear, so the container's +privileges can only ever shrink. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "securityOpt": ["no-new-privileges"] +} +``` + +## References + +- +- diff --git a/docs/rules/require-non-root.md b/docs/rules/require-non-root.md new file mode 100644 index 0000000..74adfbc --- /dev/null +++ b/docs/rules/require-non-root.md @@ -0,0 +1,45 @@ +--- +title: require-non-root +category: security +platforms: [] +file_types: [devcontainer] +description: >- + require "remoteUser" or, if unset, "containerUser" to be set to a non-root + user +--- + +*{{ page.description }}* + +## Why + +"remoteUser" defaults to whatever user the container runs as, which for most images is root. Everything +the developer's session drives then runs as root: lifecycle scripts, terminals, and the language servers +and build tools the editor starts, so a compromised dependency runs with full control of the container. +Naming an unprivileged user — as the specification's own images do — costs nothing and contains it. + +## Bad + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "root" +} +``` + +## Good + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "vscode" +} +``` + +`remoteUser` is what lifecycle scripts and the editor's remote session run as, +and it wins over `containerUser`; a rule that finds neither reports the +container's default, which is `root` for most images. + +## References + +- +- diff --git a/docs/rules/undefined-template-option.md b/docs/rules/undefined-template-option.md new file mode 100644 index 0000000..0f1bf1d --- /dev/null +++ b/docs/rules/undefined-template-option.md @@ -0,0 +1,78 @@ +--- +title: undefined-template-option +category: correctness +platforms: [] +file_types: [template] +description: >- + disallow a `${templateOption:...}` reference to an option not declared in + devcontainer-template.json +example_dir: dotnet +--- + +*{{ page.description }}* + +## Why + +Applying a Template replaces each "${templateOption:name}" with the value the user chose for the option of +that name. A reference to an option that "options" does not declare is never prompted for, and the +reference implementation substitutes the empty string for it, so a typo silently produces an empty value +in the applied files instead of an error. + +## Bad + +### `devcontainer-template.json` + +```jsonc +{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +``` + +### `.devcontainer/devcontainer.json` + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:variant}" +} +``` + +## Good + +### `devcontainer-template.json` + +```jsonc +{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +``` + +### `.devcontainer/devcontainer.json` + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:imageVariant}" +} +``` + +## References + +- +- diff --git a/docs/rules/unused-template-option.md b/docs/rules/unused-template-option.md new file mode 100644 index 0000000..97536ca --- /dev/null +++ b/docs/rules/unused-template-option.md @@ -0,0 +1,76 @@ +--- +title: unused-template-option +category: style +platforms: [] +file_types: [template] +description: >- + disallow a Template option that no file in the Template references +example_dir: dotnet +--- + +*{{ page.description }}* + +## Why + +An option only takes effect where a file substitutes it as "${templateOption:name}". One that nothing +references is still presented to the user when the Template is applied, so it asks a question whose +answer changes nothing — usually a leftover from a removed file or a renamed reference. + +## Bad + +### `devcontainer-template.json` + +```jsonc +{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +``` + +### `.devcontainer/devcontainer.json` + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/dotnet:9.0" +} +``` + +## Good + +### `devcontainer-template.json` + +```jsonc +{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +``` + +### `.devcontainer/devcontainer.json` + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:imageVariant}" +} +``` + +## References + +- +- diff --git a/linter/rule.go b/linter/rule.go index b1ab908..a076d1f 100644 --- a/linter/rule.go +++ b/linter/rule.go @@ -262,13 +262,6 @@ type Rule struct { ID string // Description is a short human-readable description of what the rule checks. Description string - // LongDescription explains why the rule exists: what goes wrong in the configuration it - // reports, and what to do instead. It is prose, wrapped as written, and is shown alongside - // References wherever a rule is documented rather than merely named. - LongDescription string - // References are URLs to the specification, documentation, or implementation that justify the - // rule, most authoritative first. - References []string // Category is the [Category] this rule reports; every rule must declare exactly one. Category Category // FileTypes are the kinds of configuration files this rule applies to. diff --git a/rules/conflicting_container_def.go b/rules/conflicting_container_def.go index 6f9c4f0..b8740b6 100644 --- a/rules/conflicting_container_def.go +++ b/rules/conflicting_container_def.go @@ -11,18 +11,10 @@ import ( var ConflictingContainerDef = &linter.Rule{ ID: "conflicting-container-def", Description: `disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile"`, - LongDescription: `The specification defines three mutually exclusive ways to create the container: from an image, from a -Dockerfile, or from a Docker Compose project. Which one wins when several are set is unspecified, so the -container that gets built depends on the tool rather than on the configuration. Keep the variant the -project actually uses and remove the others.`, - References: []string{ - "https://containers.dev/implementors/spec/#orchestration-options", - "https://containers.dev/implementors/json_reference/#scenario-specific-properties", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkConflictingContainerDef, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkConflictingContainerDef, } func checkConflictingContainerDef(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/doc_test.go b/rules/doc_test.go index 3acb427..7cb6b86 100644 --- a/rules/doc_test.go +++ b/rules/doc_test.go @@ -2,15 +2,43 @@ package rules_test import ( "net/url" + "os" + "path" + "path/filepath" "slices" "strings" "testing" + "testing/fstest" + "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" + "github.com/google/go-cmp/cmp" ) -// Every built-in rule documents itself: a short description of what it checks, a rationale, and the -// references that justify it. These tests are what keeps a new rule from landing undocumented. +// Every built-in rule is documented by a page under docsDir, published at [rules.DocsURL] and +// linked from every finding decolint reports. These tests are what keeps a rule from landing +// undocumented, a page from drifting from the rule it describes, and an example from claiming +// behavior the rule does not have. + +// docsDir holds the rule reference, one Markdown page per rule ID, relative to this package. +const docsDir = "../docs/rules" + +// docsFileNames maps a file type to the name a page's examples are linted under, which is the name +// the specification gives that kind of configuration file. +var docsFileNames = map[linter.FileType]string{ + linter.Devcontainer: "devcontainer.json", + linter.Feature: "devcontainer-feature.json", + linter.Template: "devcontainer-template.json", +} + +// unverifiableExamples are the rules whose Bad and Good examples differ in something other than the +// contents of a configuration file — a file's permission bits, or whether it exists at all — and so +// cannot be checked by linting them. Listing them here rather than skipping silently keeps the set +// from growing unnoticed. +var unverifiableExamples = []string{ + "feature-install-script-not-executable", + "missing-feature-install-script", +} func TestBuiltin_Descriptions(t *testing.T) { t.Parallel() @@ -19,40 +47,333 @@ func TestBuiltin_Descriptions(t *testing.T) { if strings.TrimSpace(reg.Rule.Description) == "" { t.Errorf("rule %s has no Description", reg.Rule.ID) } - if strings.TrimSpace(reg.Rule.LongDescription) == "" { - t.Errorf("rule %s has no LongDescription", reg.Rule.ID) + } +} + +// TestBuiltin_DocsPages checks that the rule reference and the rule registry describe the same set +// of rules, and that each page's front matter matches the rule it documents. The front matter is +// what the site renders the page's heading and index entry from, so a stale value is published. +func TestBuiltin_DocsPages(t *testing.T) { + t.Parallel() + + pages := readDocsPages(t) + var documented []string + for id := range pages { + documented = append(documented, id) + } + slices.Sort(documented) + + var registered []string + for _, reg := range rules.Builtin() { + registered = append(registered, reg.Rule.ID) + } + slices.Sort(registered) + + if diff := cmp.Diff(registered, documented); diff != "" { + t.Errorf("documented rules differ from registered rules (-registered +documented):\n%s", diff) + } + + for _, reg := range rules.Builtin() { + page, ok := pages[reg.Rule.ID] + if !ok { + continue // already reported by the diff above + } + want := map[string]string{ + "title": reg.Rule.ID, + "category": reg.Rule.Category.String(), + "platforms": strings.Join(platformNames(reg.Rule.Platforms), ", "), + "file_types": strings.Join(fileTypeNames(reg.Rule.FileTypes), ", "), + "description": reg.Rule.Description, + } + got := map[string]string{ + "title": page.frontMatter["title"], + "category": page.frontMatter["category"], + "platforms": page.frontMatter["platforms"], + "file_types": page.frontMatter["file_types"], + "description": page.frontMatter["description"], } - if got := reg.Rule.LongDescription; got != strings.TrimSpace(got) { - t.Errorf("rule %s LongDescription has leading or trailing whitespace", reg.Rule.ID) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("rule %s front matter mismatch (-rule +page):\n%s", reg.Rule.ID, diff) } } } -func TestBuiltin_References(t *testing.T) { +// TestBuiltin_DocsExamples lints each page's Bad and Good examples with only the rule the page +// documents enabled: the Bad example must report it and the Good example must not. It is what stops +// an example from teaching configuration the rule does not actually accept or reject. +func TestBuiltin_DocsExamples(t *testing.T) { t.Parallel() + pages := readDocsPages(t) + var skipped []string for _, reg := range rules.Builtin() { - if len(reg.Rule.References) == 0 { - t.Errorf("rule %s has no References", reg.Rule.ID) + page, ok := pages[reg.Rule.ID] + if !ok { + continue // reported by TestBuiltin_DocsPages + } + if !page.verifiable { + skipped = append(skipped, reg.Rule.ID) + continue } - for _, ref := range reg.Rule.References { - // A reference is rendered as a link wherever it is shown, so it has to be a URL a reader - // can follow on its own, not a bare path or a prose citation. + + t.Run(reg.Rule.ID, func(t *testing.T) { + t.Parallel() + + if issues := lintDocsExample(t, reg.Rule, page, page.bad); len(issues) == 0 { + t.Errorf("Bad example reports nothing; it must trip %s", reg.Rule.ID) + } + if issues := lintDocsExample(t, reg.Rule, page, page.good); len(issues) > 0 { + t.Errorf("Good example reports %d issue(s), want none: %v", len(issues), issues) + } + }) + } + + slices.Sort(skipped) + if diff := cmp.Diff(unverifiableExamples, skipped); diff != "" { + t.Errorf("unverifiable examples differ from the documented set (-want +got):\n%s", diff) + } +} + +// TestBuiltin_DocsReferences checks the links each page cites as its justification: a reader has to +// be able to follow them, so each must be an absolute https URL, and citing one twice is a mistake. +func TestBuiltin_DocsReferences(t *testing.T) { + t.Parallel() + + for id, page := range readDocsPages(t) { + if len(page.references) == 0 { + t.Errorf("rule %s documents no references", id) + } + for _, ref := range page.references { u, err := url.Parse(ref) if err != nil { - t.Errorf("rule %s reference %q does not parse: %v", reg.Rule.ID, ref, err) + t.Errorf("rule %s reference %q does not parse: %v", id, ref, err) continue } if u.Scheme != "https" || u.Host == "" { - t.Errorf("rule %s reference %q is not an absolute https URL", reg.Rule.ID, ref) + t.Errorf("rule %s reference %q is not an absolute https URL", id, ref) } } - if i := duplicateIndex(reg.Rule.References); i >= 0 { - t.Errorf("rule %s lists reference %q twice", reg.Rule.ID, reg.Rule.References[i]) + if i := duplicateIndex(page.references); i >= 0 { + t.Errorf("rule %s cites reference %q twice", id, page.references[i]) } } } +func TestDocsURL(t *testing.T) { + t.Parallel() + + got := rules.DocsURL("no-image-latest") + want := "https://bare-devcontainer.github.io/decolint/rules/no-image-latest/" + if got != want { + t.Errorf("DocsURL = %q, want %q", got, want) + } +} + +// docsPage is one rule's documentation page. +type docsPage struct { + frontMatter map[string]string + // bad and good are the files making up each example, in the order the page shows them. + bad, good []docsFile + // verifiable reports whether the examples can be checked by linting them; see + // unverifiableExamples. + verifiable bool + references []string +} + +// docsFile is one file of an example: the name it is linted under and its contents. +type docsFile struct { + name, source string +} + +// readDocsPages parses every rule page in docsDir, keyed by the rule ID its file is named after. +func readDocsPages(t *testing.T) map[string]docsPage { + t.Helper() + + entries, err := os.ReadDir(docsDir) + if err != nil { + t.Fatalf("read %s: %v", docsDir, err) + } + + pages := make(map[string]docsPage, len(entries)) + for _, e := range entries { + name := e.Name() + if e.IsDir() || filepath.Ext(name) != ".md" || name == "index.md" { + continue + } + src, err := os.ReadFile(filepath.Join(docsDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + pages[strings.TrimSuffix(name, ".md")] = parseDocsPage(t, name, string(src)) + } + if len(pages) == 0 { + t.Fatalf("no rule pages found in %s", docsDir) + } + return pages +} + +// parseDocsPage parses a rule page: YAML front matter delimited by "---" lines, then "## Bad", +// "## Good" and "## References" sections. Within a section, a fenced jsonc block is one file of the +// example, named by the "### `name`" heading before it or, with no heading, by the rule's own file +// type. +func parseDocsPage(t *testing.T, name, src string) docsPage { + t.Helper() + + body, ok := strings.CutPrefix(src, "---\n") + if !ok { + t.Fatalf("%s: no front matter", name) + } + fm, body, ok := strings.Cut(body, "\n---\n") + if !ok { + t.Fatalf("%s: front matter is not terminated", name) + } + + page := docsPage{frontMatter: parseFrontMatter(t, name, fm)} + page.verifiable = page.frontMatter["example_verify"] != "false" + + var section, fileName, fence string + var block strings.Builder + for _, line := range strings.Split(body, "\n") { + if fence != "" { + if strings.TrimSpace(line) == fence { + if fileName != "" { + file := docsFile{name: fileName, source: block.String()} + switch section { + case "Bad": + page.bad = append(page.bad, file) + case "Good": + page.good = append(page.good, file) + } + } + fence, fileName = "", "" + block.Reset() + continue + } + block.WriteString(line + "\n") + continue + } + switch { + case strings.HasPrefix(line, "## "): + section = strings.TrimSpace(strings.TrimPrefix(line, "## ")) + case strings.HasPrefix(line, "### "): + fileName = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "### ")), "`") + case strings.HasPrefix(line, "```jsonc"): + fence = "```" + if fileName == "" { + fileName = defaultDocsFileName(t, name, page.frontMatter["file_types"]) + } + case strings.HasPrefix(line, "```"): + // A block in another language documents something that is not a configuration file, so + // it is shown to the reader but never linted. + fence = "```" + fileName = "" + case section == "References" && strings.HasPrefix(line, "- <"): + page.references = append(page.references, strings.Trim(strings.TrimPrefix(line, "- "), "<>")) + } + } + return page +} + +// parseFrontMatter parses the subset of YAML the rule pages use: "key: value", a flow sequence +// "key: [a, b]" kept as its comma-separated contents, and a folded block scalar "key: >-" whose +// indented continuation lines are joined with single spaces. +func parseFrontMatter(t *testing.T, name, src string) map[string]string { + t.Helper() + + out := map[string]string{} + var folding string + for _, line := range strings.Split(src, "\n") { + if folding != "" { + if strings.HasPrefix(line, " ") { + out[folding] = strings.TrimSpace(out[folding] + " " + strings.TrimSpace(line)) + continue + } + folding = "" + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + switch { + case value == ">-": + folding = key + out[key] = "" + case strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]"): + out[key] = strings.TrimSuffix(strings.TrimPrefix(value, "["), "]") + default: + out[key] = value + } + } + if len(out) == 0 { + t.Fatalf("%s: front matter has no fields", name) + } + return out +} + +// defaultDocsFileName returns the file name an unlabelled example block is linted under: the name +// of the first file type in the page's front matter, which is the kind of file the rule's own +// examples are written as. +func defaultDocsFileName(t *testing.T, name, fileTypes string) string { + t.Helper() + + first, _, _ := strings.Cut(fileTypes, ",") + fileName, ok := docsFileNames[linter.FileType(strings.TrimSpace(first))] + if !ok { + t.Fatalf("%s: front matter names no known file type, got %q", name, fileTypes) + } + return fileName +} + +// lintDocsExample lints one example with rule as the only active rule and returns what it reports. +// Every file of the example is visible to rules that read sibling files; the one named after the +// rule's file type is the one linted. +func lintDocsExample(t *testing.T, rule *linter.Rule, page docsPage, files []docsFile) []linter.Issue { + t.Helper() + + fileName := docsFileNames[rule.FileTypes[0]] + fsys := fstest.MapFS{} + var source string + var found bool + for _, f := range files { + fsys[path.Clean(f.name)] = &fstest.MapFile{Data: []byte(f.source)} + if f.name == fileName { + source, found = f.source, true + } + } + if !found { + t.Fatalf("example has no %s block to lint, got %d file(s)", fileName, len(files)) + } + + doc, err := linter.ParseDocument([]byte(source)) + if err != nil { + t.Fatalf("parse %s example: %v", fileName, err) + } + + l := linter.New() + l.RegisterRule(rule, linter.SeverityError) + return l.LintDocument(fileName, rule.FileTypes[0], doc, linter.Dir{ + FS: fsys, + Name: page.frontMatter["example_dir"], + }) +} + +func platformNames(platforms []linter.Platform) []string { + names := make([]string, len(platforms)) + for i, p := range platforms { + names[i] = p.String() + } + return names +} + +func fileTypeNames(fileTypes []linter.FileType) []string { + names := make([]string, len(fileTypes)) + for i, ft := range fileTypes { + names[i] = string(ft) + } + return names +} + // duplicateIndex returns the index of the first element of refs that appears earlier in it, or -1 // if every element is unique. func duplicateIndex(refs []string) int { diff --git a/rules/docs.go b/rules/docs.go new file mode 100644 index 0000000..4fe2a35 --- /dev/null +++ b/rules/docs.go @@ -0,0 +1,12 @@ +package rules + +// docsBaseURL is where the rule reference is published. The pages themselves live in the +// repository's docs directory, one Markdown file per rule ID. +const docsBaseURL = "https://bare-devcontainer.github.io/decolint/rules/" + +// DocsURL returns the address of the page documenting the rule with the given ID: what it checks, +// why, and configuration it accepts and rejects. The address is derived from the ID, so it is +// returned for any id, including one no built-in rule has. +func DocsURL(id string) string { + return docsBaseURL + id + "/" +} diff --git a/rules/feature_install_script_not_executable.go b/rules/feature_install_script_not_executable.go index 69ac7fd..9ba8c4b 100644 --- a/rules/feature_install_script_not_executable.go +++ b/rules/feature_install_script_not_executable.go @@ -14,16 +14,10 @@ import ( var FeatureInstallScriptNotExecutable = &linter.Rule{ ID: "feature-install-script-not-executable", Description: "disallow a Feature's `install.sh` that lacks executable permission bits", - LongDescription: `The specification has the installing tool invoke "install.sh" directly rather than through a shell, so -that the script's own shebang selects the interpreter. That requires the execute bit: without it the -Feature fails to install when a container is built. Run "chmod +x install.sh" and commit the mode change.`, - References: []string{ - "https://containers.dev/implementors/features/#invoking-installsh", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature}, - Paths: []string{""}, - Check: checkFeatureInstallScriptNotExecutable, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Paths: []string{""}, + Check: checkFeatureInstallScriptNotExecutable, } func checkFeatureInstallScriptNotExecutable(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/id_dir_mismatch.go b/rules/id_dir_mismatch.go index 9d3c398..9256d12 100644 --- a/rules/id_dir_mismatch.go +++ b/rules/id_dir_mismatch.go @@ -12,18 +12,10 @@ import ( var IDDirMismatch = &linter.Rule{ ID: "id-dir-mismatch", Description: `disallow a Feature's or Template's "id" that does not match the name of its containing directory`, - LongDescription: `Both specifications require the "id" to match the name of the directory holding the metadata file, since -that directory name is what packaging and distribution address the artifact by. When the two disagree the -published reference does not resolve to what the directory contains; rename the directory or the "id" so -they agree.`, - References: []string{ - "https://containers.dev/implementors/features/#devcontainer-featurejson-properties", - "https://containers.dev/implementors/templates/#devcontainer-templatejson-properties", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{"/id"}, - Check: checkIDDirMismatch, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{"/id"}, + Check: checkIDDirMismatch, } func checkIDDirMismatch(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/invalid_semver.go b/rules/invalid_semver.go index 6fbf3c9..424cd6e 100644 --- a/rules/invalid_semver.go +++ b/rules/invalid_semver.go @@ -13,17 +13,10 @@ import ( var InvalidSemver = &linter.Rule{ ID: "invalid-semver", Description: `disallow a Feature's or Template's "version" that is not a valid semantic version`, - LongDescription: `Publishing a Feature or Template pushes it under tags derived from the "version" components: the full -version, "major.minor", and "major", so consumers can pin as loosely or as tightly as they want. A value -that is not valid semver has no such components, leaving nothing to derive those tags from.`, - References: []string{ - "https://containers.dev/implementors/features-distribution/#versioning", - "https://semver.org/", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{"/version"}, - Check: checkInvalidSemver, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{"/version"}, + Check: checkInvalidSemver, } // semverPattern is the official semantic version regular expression published at diff --git a/rules/missing_build_dockerfile.go b/rules/missing_build_dockerfile.go index 095b994..452879e 100644 --- a/rules/missing_build_dockerfile.go +++ b/rules/missing_build_dockerfile.go @@ -10,17 +10,10 @@ import ( var MissingBuildDockerfile = &linter.Rule{ ID: "missing-build-dockerfile", Description: `disallow a devcontainer.json "build" object that is missing "dockerfile"`, - LongDescription: `"build.dockerfile" is the only required member of "build": it locates, relative to the devcontainer.json, -the Dockerfile the image is built from. The other members ("context", "args", "target", ...) only shape a -build that "dockerfile" defines, so without it there is nothing to build.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", - "https://containers.dev/implementors/spec/#dockerfile-based", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/build"}, - Check: checkMissingBuildDockerfile, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/build"}, + Check: checkMissingBuildDockerfile, } func checkMissingBuildDockerfile(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_compose_service.go b/rules/missing_compose_service.go index b2cde9e..6bad04f 100644 --- a/rules/missing_compose_service.go +++ b/rules/missing_compose_service.go @@ -10,17 +10,10 @@ import ( var MissingComposeService = &linter.Rule{ ID: "missing-compose-service", Description: `disallow a devcontainer.json that sets "dockerComposeFile" without "service"`, - LongDescription: `A Compose project usually defines several services, so naming the Compose file does not say which -container the tooling should attach to. The specification requires "service" to name that main container: -it is the one lifecycle scripts run in and the one editors connect to.`, - References: []string{ - "https://containers.dev/implementors/spec/#docker-compose-based", - "https://containers.dev/implementors/json_reference/#docker-compose-specific-properties", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingComposeService, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkMissingComposeService, } func checkMissingComposeService(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_container_def.go b/rules/missing_container_def.go index 3d25ea0..2f432e8 100644 --- a/rules/missing_container_def.go +++ b/rules/missing_container_def.go @@ -10,17 +10,10 @@ import ( var MissingContainerDef = &linter.Rule{ ID: "missing-container-def", Description: `disallow a devcontainer.json that defines none of "image", "build", or "dockerComposeFile"`, - LongDescription: `Every dev container is created from exactly one of "image", "build", or "dockerComposeFile", and each of -the three is required in its own scenario. A configuration that sets none of them describes no container -at all, so no tool can create one from it.`, - References: []string{ - "https://containers.dev/implementors/spec/#orchestration-options", - "https://containers.dev/implementors/json_reference/#scenario-specific-properties", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingContainerDef, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkMissingContainerDef, } func checkMissingContainerDef(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_feature_install_script.go b/rules/missing_feature_install_script.go index e273a05..0b16cd3 100644 --- a/rules/missing_feature_install_script.go +++ b/rules/missing_feature_install_script.go @@ -16,17 +16,10 @@ const installScriptName = "install.sh" var MissingFeatureInstallScript = &linter.Rule{ ID: "missing-feature-install-script", Description: "disallow a Feature directory without the required `install.sh` install script", - LongDescription: `A Feature is distributed as its metadata file plus the "install.sh" the tooling runs inside the container, -which is where the Feature does all of its work. A directory without one publishes a Feature that -installs nothing, and the omission only surfaces when someone builds a container with it.`, - References: []string{ - "https://containers.dev/implementors/features/#folder-structure", - "https://containers.dev/implementors/features/#invoking-installsh", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature}, - Paths: []string{""}, - Check: checkMissingFeatureInstallScript, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Paths: []string{""}, + Check: checkMissingFeatureInstallScript, } func checkMissingFeatureInstallScript(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_required_props.go b/rules/missing_required_props.go index 4f2604e..eeebe46 100644 --- a/rules/missing_required_props.go +++ b/rules/missing_required_props.go @@ -12,17 +12,10 @@ import ( var MissingRequiredProps = &linter.Rule{ ID: "missing-required-props", Description: `disallow a Feature's or Template's metadata that is missing a required property ("id", "version", or "name")`, - LongDescription: `"id", "version", and "name" are the only properties either specification requires: the "id" addresses the -artifact, the "version" is what consumers pin to, and the "name" is what a user recognizes it by in a -list. Metadata missing any of them cannot be published as a usable Feature or Template.`, - References: []string{ - "https://containers.dev/implementors/features/#devcontainer-featurejson-properties", - "https://containers.dev/implementors/templates/#devcontainer-templatejson-properties", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{""}, - Check: checkMissingRequiredProps, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{""}, + Check: checkMissingRequiredProps, } func checkMissingRequiredProps(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_workspace_mount_folder.go b/rules/missing_workspace_mount_folder.go index 246589c..c59d90e 100644 --- a/rules/missing_workspace_mount_folder.go +++ b/rules/missing_workspace_mount_folder.go @@ -13,18 +13,10 @@ import ( var MissingWorkspaceMountFolder = &linter.Rule{ ID: "missing-workspace-mount-folder", Description: `disallow a devcontainer.json using "image" or "build" that sets only one of "workspaceMount" or "workspaceFolder"`, - LongDescription: `The two properties describe opposite ends of the same override: "workspaceMount" says where the source -code is mounted, "workspaceFolder" says which path inside the container the tooling opens. The reference -documents each as requiring the other, because setting one alone either mounts the source somewhere -nothing opens, or opens a path nothing is mounted at.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", - "https://containers.dev/implementors/spec/#workspacefolder-and-workspacemount", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingWorkspaceMountFolder, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkMissingWorkspaceMountFolder, } func checkMissingWorkspaceMountFolder(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_app_port.go b/rules/no_app_port.go index 04a5097..dc9bbd5 100644 --- a/rules/no_app_port.go +++ b/rules/no_app_port.go @@ -8,18 +8,10 @@ import "github.com/bare-devcontainer/decolint/linter" var NoAppPort = &linter.Rule{ ID: "no-app-port", Description: `disallow the legacy "appPort" property in favor of "forwardPorts"`, - LongDescription: `"appPort" publishes the port the way Docker does: it is fixed when the container is created, and the -application has to listen on all interfaces rather than just "localhost" to be reachable. A forwarded -port instead looks like "localhost" to the application and can be changed without recreating the -container, which is why the reference recommends "forwardPorts" in most cases.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", - "https://containers.dev/implementors/json_reference/#publishing-vs-forwarding-ports", - }, - Category: linter.CategoryStyle, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/appPort"}, - Check: checkNoAppPort, + Category: linter.CategoryStyle, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/appPort"}, + Check: checkNoAppPort, } func checkNoAppPort(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_bind_mount.go b/rules/no_bind_mount.go index 4d4ca32..f087d4d 100644 --- a/rules/no_bind_mount.go +++ b/rules/no_bind_mount.go @@ -10,19 +10,11 @@ import ( var NoBindMount = &linter.Rule{ ID: "no-bind-mount", Description: `disallow "bind" type entries in "mounts", which GitHub Codespaces silently ignores except for the Docker socket`, - LongDescription: `A codespace runs on a machine in the cloud, where the host path a bind mount points at does not exist, so -Codespaces documents that it ignores "bind" mounts apart from the Docker socket. The mount is dropped -without an error and the container starts missing the data it expects. Volume mounts are honored, so use -"type=volume" for anything that only has to persist across rebuilds.`, - References: []string{ - "https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#github-codespaces", - "https://containers.dev/implementors/spec/#mounts", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformCodespaces}, - Paths: []string{"/mounts/*"}, - Check: checkNoBindMount, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Paths: []string{"/mounts/*"}, + Check: checkNoBindMount, } func checkNoBindMount(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_cap_add_all.go b/rules/no_cap_add_all.go index 80f2ea5..c3505da 100644 --- a/rules/no_cap_add_all.go +++ b/rules/no_cap_add_all.go @@ -12,18 +12,10 @@ import ( var NoCapAddAll = &linter.Rule{ ID: "no-cap-add-all", Description: `disallow granting all Linux capabilities via an "ALL" entry in the "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's "runArgs"`, - LongDescription: `Linux capabilities split root's powers into units a container can be granted individually, and the runtime -withholds the dangerous ones by default. "ALL" hands them all over, including capabilities such as -"SYS_ADMIN" and "SYS_MODULE" that let a process reconfigure the host kernel and escape the container. -"capAdd" exists to name the one or two a workload actually needs, e.g. "SYS_PTRACE" for a debugger.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.docker.com/engine/security/#linux-kernel-capabilities", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/capAdd/*", "/runArgs"}, - Check: checkNoCapAddAll, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/capAdd/*", "/runArgs"}, + Check: checkNoCapAddAll, } func checkNoCapAddAll(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_docker_socket_mount.go b/rules/no_docker_socket_mount.go index dcc3252..df903eb 100644 --- a/rules/no_docker_socket_mount.go +++ b/rules/no_docker_socket_mount.go @@ -14,19 +14,10 @@ import ( var NoDockerSocketMount = &linter.Rule{ ID: "no-docker-socket-mount", Description: `disallow bind-mounting the host's Docker socket via a devcontainer.json's "mounts" or "runArgs", which grants the container root-equivalent control over the host`, - LongDescription: `The Docker socket is the daemon's full API, and the daemon runs as root on the host. Anything that can -reach the socket can start a container that mounts the host's filesystem, so mounting it into the dev -container hands root-equivalent control of the host to every process inside — including code the -project's own build fetches. When the container genuinely needs Docker, a Docker-in-Docker Feature or a -rootless daemon keeps that access inside the container.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.docker.com/engine/security/#docker-daemon-attack-surface", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/mounts/*", "/runArgs/*"}, - Check: checkNoDockerSocketMount, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/mounts/*", "/runArgs/*"}, + Check: checkNoDockerSocketMount, } func checkNoDockerSocketMount(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_host_port_format.go b/rules/no_host_port_format.go index fbc6e3b..31fd1d6 100644 --- a/rules/no_host_port_format.go +++ b/rules/no_host_port_format.go @@ -15,19 +15,11 @@ import ( var NoHostPortFormat = &linter.Rule{ ID: "no-host-port-format", Description: `disallow "host:port" entries in "forwardPorts" and "portsAttributes", which GitHub Codespaces does not support`, - LongDescription: `The "host:port" form forwards a port from another container in a Docker Compose project (e.g. "db:5432") -rather than from the primary one. Codespaces documents that it does not support that variation of either -property, so the entry is ignored there and the port is not forwarded. A bare port number, which refers -to the primary container, works everywhere.`, - References: []string{ - "https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#github-codespaces", - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformCodespaces}, - Paths: []string{"/forwardPorts/*", "/portsAttributes/*"}, - Check: checkNoHostPortFormat, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Paths: []string{"/forwardPorts/*", "/portsAttributes/*"}, + Check: checkNoHostPortFormat, } func checkNoHostPortFormat(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_image_latest.go b/rules/no_image_latest.go index c5b7b07..5df6680 100644 --- a/rules/no_image_latest.go +++ b/rules/no_image_latest.go @@ -13,17 +13,10 @@ import ( 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.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", - }, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, - Check: checkNoImageLatest, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/image"}, + Check: checkNoImageLatest, } func checkNoImageLatest(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_privileged_container.go b/rules/no_privileged_container.go index fb9ff3e..fb9df83 100644 --- a/rules/no_privileged_container.go +++ b/rules/no_privileged_container.go @@ -12,19 +12,10 @@ import ( var NoPrivilegedContainer = &linter.Rule{ ID: "no-privileged-container", Description: `disallow running the container in privileged mode via the "privileged" property or a "--privileged" entry in "runArgs"`, - LongDescription: `A privileged container gets every Linux capability, unconfined seccomp and LSM profiles, and access to all -host devices. That removes essentially every boundary between the container and the host, so any code -running in it — including a compromised dependency pulled in by the project's own build — can take over -the machine. Docker-in-Docker is the usual reason it is set; a Feature that provides it, or the specific -capabilities and devices the workload needs, is a far narrower grant.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.docker.com/engine/security/#docker-daemon-attack-surface", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/privileged", "/runArgs/*"}, - Check: checkNoPrivilegedContainer, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/privileged", "/runArgs/*"}, + Check: checkNoPrivilegedContainer, } func checkNoPrivilegedContainer(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_seccomp_override.go b/rules/no_seccomp_override.go index ce822bd..1e7fd00 100644 --- a/rules/no_seccomp_override.go +++ b/rules/no_seccomp_override.go @@ -16,19 +16,10 @@ import ( var NoSeccompOverride = &linter.Rule{ ID: "no-seccomp-override", Description: `disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs"`, - LongDescription: `The runtime's default seccomp profile blocks the syscalls containers do not need, several of which have -featured in container escapes. Pointing "seccomp" at a profile of your own replaces that default -wholesale, and a hand-written profile is rarely reviewed as carefully or updated as the kernel gains new -syscalls. Keep the default unless the workload provably needs more, and review the replacement if it -does.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.docker.com/engine/security/seccomp/", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs/*"}, - Check: checkNoSeccompOverride, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/securityOpt/*", "/runArgs/*"}, + Check: checkNoSeccompOverride, } func checkNoSeccompOverride(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_seccomp_unconfined.go b/rules/no_seccomp_unconfined.go index 4350f0b..ddc9941 100644 --- a/rules/no_seccomp_unconfined.go +++ b/rules/no_seccomp_unconfined.go @@ -12,18 +12,10 @@ import ( var NoSeccompUnconfined = &linter.Rule{ ID: "no-seccomp-unconfined", Description: `disallow disabling seccomp confinement via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" entry in a devcontainer.json's "runArgs"`, - LongDescription: `"seccomp=unconfined" turns off syscall filtering entirely, exposing the whole kernel API — including the -calls the default profile blocks precisely because they have been used to break out of containers. The -setting is most often copied from debugger instructions, where granting the "SYS_PTRACE" capability is -enough on current runtimes.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.docker.com/engine/security/seccomp/", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs"}, - Check: checkNoSeccompUnconfined, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/securityOpt/*", "/runArgs"}, + Check: checkNoSeccompUnconfined, } func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_extension_version.go b/rules/pin_extension_version.go index 82c7642..91c80db 100644 --- a/rules/pin_extension_version.go +++ b/rules/pin_extension_version.go @@ -15,19 +15,11 @@ import ( var PinExtensionVersion = &linter.Rule{ ID: "pin-extension-version", Description: `disallow a "customizations.vscode.extensions" entry without an explicit pinned version`, - LongDescription: `An extension ID on its own installs whatever the marketplace publishes at the moment the container is -created, so two developers on the same devcontainer.json can end up with different formatters, linters, or -language server versions — and an extension update can change the environment without any commit. -Appending a version ("publisher.name@1.2.3") makes the editor tooling as pinned as the rest of the image.`, - References: []string{ - "https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#visual-studio-code", - "https://code.visualstudio.com/docs/configure/extensions/extension-marketplace", - }, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, - Paths: []string{"/customizations/vscode/extensions/*"}, - Check: checkPinExtensionVersion, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, + Paths: []string{"/customizations/vscode/extensions/*"}, + Check: checkPinExtensionVersion, } func checkPinExtensionVersion(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_feature_version.go b/rules/pin_feature_version.go index d7c0092..6535582 100644 --- a/rules/pin_feature_version.go +++ b/rules/pin_feature_version.go @@ -16,18 +16,10 @@ import ( var PinFeatureVersion = &linter.Rule{ ID: "pin-feature-version", Description: `disallow a Feature reference without an explicit version or with the "latest" version`, - LongDescription: `A Feature reference with no version resolves to "latest", so the container installs whatever the Feature's -author published most recently — the tooling it sets up can change under the project without the -devcontainer.json changing at all. Features are published under their full version as well as -"major.minor" and "major" tags, so a reference can be pinned as tightly as the project wants.`, - References: []string{ - "https://containers.dev/implementors/features-distribution/#versioning", - "https://containers.dev/implementors/features/#referencing-a-feature", - }, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/features"}, - Check: checkPinFeatureVersion, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/features"}, + Check: checkPinFeatureVersion, } func checkPinFeatureVersion(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_image_digest.go b/rules/pin_image_digest.go index 0db84f2..434f06d 100644 --- a/rules/pin_image_digest.go +++ b/rules/pin_image_digest.go @@ -21,18 +21,10 @@ var digestSuffix = regexp.MustCompile(`@[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9 var PinImageDigest = &linter.Rule{ ID: "pin-image-digest", Description: `disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...")`, - LongDescription: `A tag is a mutable pointer: the publisher can move even a fully specified one to different bits, and a -registry can serve a different image for the same tag on a different day. A digest names the content -itself, so "image@sha256:..." always resolves to the exact image the project was tested with, and the -client verifies what it pulled against it.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties", - "https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests", - }, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, - Check: checkPinImageDigest, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/image"}, + Check: checkPinImageDigest, } func checkPinImageDigest(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_cap_drop_all.go b/rules/require_cap_drop_all.go index 336d436..5bd459c 100644 --- a/rules/require_cap_drop_all.go +++ b/rules/require_cap_drop_all.go @@ -12,18 +12,10 @@ import ( var RequireCapDropAll = &linter.Rule{ ID: "require-cap-drop-all", Description: `require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", dropping every Linux capability`, - LongDescription: `Container runtimes grant a default set of capabilities that a dev container almost never uses: raw network -access, changing file ownership, or binding privileged ports. Dropping all of them and adding back only -what the workload needs ("capAdd") means a process that is compromised inherits no privilege the project -never asked for.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.docker.com/engine/security/#linux-kernel-capabilities", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireCapDropAll, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkRequireCapDropAll, } func checkRequireCapDropAll(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_no_new_privileges.go b/rules/require_no_new_privileges.go index 98ff1ce..ce3c718 100644 --- a/rules/require_no_new_privileges.go +++ b/rules/require_no_new_privileges.go @@ -13,18 +13,10 @@ import ( var RequireNoNewPrivileges = &linter.Rule{ ID: "require-no-new-privileges", Description: `require "no-new-privileges" to be set via a devcontainer.json's "securityOpt" property, or a "--security-opt no-new-privileges..." entry in "runArgs"`, - LongDescription: `Without this option a process in the container can still gain privileges it was not started with, by -executing a setuid binary — which undercuts the point of running as a non-root user. Setting it raises the -kernel's "no_new_privs" bit, which every child process inherits and none can clear, so the container's -privileges can only ever shrink.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties", - "https://docs.kernel.org/userspace-api/no_new_privs.html", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireNoNewPrivileges, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkRequireNoNewPrivileges, } func checkRequireNoNewPrivileges(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_non_root.go b/rules/require_non_root.go index 4b76f78..02aa4b5 100644 --- a/rules/require_non_root.go +++ b/rules/require_non_root.go @@ -16,18 +16,10 @@ import ( var RequireNonRoot = &linter.Rule{ ID: "require-non-root", Description: `require "remoteUser" or, if unset, "containerUser" to be set to a non-root user`, - LongDescription: `"remoteUser" defaults to whatever user the container runs as, which for most images is root. Everything -the developer's session drives then runs as root: lifecycle scripts, terminals, and the language servers -and build tools the editor starts, so a compromised dependency runs with full control of the container. -Naming an unprivileged user — as the specification's own images do — costs nothing and contains it.`, - References: []string{ - "https://containers.dev/implementors/json_reference/#remoteuser", - "https://containers.dev/implementors/spec/#users", - }, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireNonRoot, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Check: checkRequireNonRoot, } func checkRequireNonRoot(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/undefined_template_option.go b/rules/undefined_template_option.go index cdc96db..2b76fc1 100644 --- a/rules/undefined_template_option.go +++ b/rules/undefined_template_option.go @@ -16,18 +16,10 @@ import ( var UndefinedTemplateOption = &linter.Rule{ ID: "undefined-template-option", Description: "disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json", - LongDescription: `Applying a Template replaces each "${templateOption:name}" with the value the user chose for the option of -that name. A reference to an option that "options" does not declare is never prompted for, and the -reference implementation substitutes the empty string for it, so a typo silently produces an empty value -in the applied files instead of an error.`, - References: []string{ - "https://containers.dev/implementors/templates/#the-options-property", - "https://github.com/devcontainers/cli", - }, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Template}, - Paths: []string{""}, - Check: checkUndefinedTemplateOption, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Template}, + Paths: []string{""}, + Check: checkUndefinedTemplateOption, } func checkUndefinedTemplateOption(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/unused_template_option.go b/rules/unused_template_option.go index bd828af..73c0418 100644 --- a/rules/unused_template_option.go +++ b/rules/unused_template_option.go @@ -13,17 +13,10 @@ import ( var UnusedTemplateOption = &linter.Rule{ ID: "unused-template-option", Description: "disallow a Template option that no file in the Template references", - LongDescription: `An option only takes effect where a file substitutes it as "${templateOption:name}". One that nothing -references is still presented to the user when the Template is applied, so it asks a question whose -answer changes nothing — usually a leftover from a removed file or a renamed reference.`, - References: []string{ - "https://containers.dev/implementors/templates/#the-options-property", - "https://containers.dev/implementors/templates/#option-resolution", - }, - Category: linter.CategoryStyle, - FileTypes: []linter.FileType{linter.Template}, - Paths: []string{"/options"}, - Check: checkUnusedTemplateOption, + Category: linter.CategoryStyle, + FileTypes: []linter.FileType{linter.Template}, + Paths: []string{"/options"}, + Check: checkUnusedTemplateOption, } func checkUnusedTemplateOption(ctx *linter.Context, node *linter.Node) []linter.Finding { From 3f3954cc3d6f6175982433c462b22c0866716cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 22:53:39 +0000 Subject: [PATCH 04/24] ci: build the documentation site with Hugo instead of Jekyll Jekyll runs Liquid over every file before converting it, code fences included, so a snippet containing a GitHub Actions expression was silently emptied: "token: ${{ secrets.GITHUB_TOKEN }}" published as "token: $", with the build reporting success. Hugo leaves it alone, since only "{{<" and "{{%" delimit its shortcodes. The move also drops the theme. layouts/ and assets/css/ are the site's own, and the rule index and sidebar are now built from the rule pages themselves rather than a hand-maintained table, so the list cannot fall behind the pages the way it already had. Published paths are unchanged, so the addresses rules.DocsURL derives still resolve. Hugo is pinned by version and checksum in the Pages workflow and named in the Makefile, which grows site, site-serve and site-syntax targets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- .github/workflows/pages.yml | 30 ++- .gitignore | 3 + CONTRIBUTING.md | 25 +- Makefile | 29 ++- docs/_config.yml | 27 -- docs/assets/css/main.css | 243 ++++++++++++++++++ docs/assets/css/syntax.css | 159 ++++++++++++ docs/{index.md => content/_index.md} | 0 docs/{ => content}/getting-started.md | 0 docs/content/rules/_index.md | 10 + .../rules/conflicting-container-def.md | 2 - .../feature-install-script-not-executable.md | 2 - docs/{ => content}/rules/id-dir-mismatch.md | 2 - docs/{ => content}/rules/invalid-semver.md | 2 - .../rules/missing-build-dockerfile.md | 2 - .../rules/missing-compose-service.md | 2 - .../rules/missing-container-def.md | 2 - .../rules/missing-feature-install-script.md | 2 - .../rules/missing-required-props.md | 2 - .../rules/missing-workspace-mount-folder.md | 2 - docs/{ => content}/rules/no-app-port.md | 2 - docs/{ => content}/rules/no-bind-mount.md | 2 - docs/{ => content}/rules/no-cap-add-all.md | 2 - .../rules/no-docker-socket-mount.md | 2 - .../rules/no-host-port-format.md | 2 - docs/{ => content}/rules/no-image-latest.md | 2 - .../rules/no-privileged-container.md | 2 - .../rules/no-seccomp-override.md | 2 - .../rules/no-seccomp-unconfined.md | 2 - .../rules/pin-extension-version.md | 2 - .../rules/pin-feature-version.md | 2 - docs/{ => content}/rules/pin-image-digest.md | 2 - .../rules/require-cap-drop-all.md | 2 - .../rules/require-no-new-privileges.md | 2 - docs/{ => content}/rules/require-non-root.md | 2 - .../rules/undefined-template-option.md | 2 - .../rules/unused-template-option.md | 2 - docs/data/categories.yaml | 14 + docs/hugo.toml | 45 ++++ docs/layouts/baseof.html | 37 +++ docs/layouts/home.html | 4 + docs/layouts/page.html | 19 ++ docs/layouts/partials/rule-nav.html | 17 ++ docs/layouts/rules/section.html | 30 +++ docs/rules/index.md | 63 ----- rules/doc_test.go | 5 +- 46 files changed, 653 insertions(+), 161 deletions(-) delete mode 100644 docs/_config.yml create mode 100644 docs/assets/css/main.css create mode 100644 docs/assets/css/syntax.css rename docs/{index.md => content/_index.md} (100%) rename docs/{ => content}/getting-started.md (100%) create mode 100644 docs/content/rules/_index.md rename docs/{ => content}/rules/conflicting-container-def.md (97%) rename docs/{ => content}/rules/feature-install-script-not-executable.md (97%) rename docs/{ => content}/rules/id-dir-mismatch.md (97%) rename docs/{ => content}/rules/invalid-semver.md (96%) rename docs/{ => content}/rules/missing-build-dockerfile.md (97%) rename docs/{ => content}/rules/missing-compose-service.md (97%) rename docs/{ => content}/rules/missing-container-def.md (97%) rename docs/{ => content}/rules/missing-feature-install-script.md (97%) rename docs/{ => content}/rules/missing-required-props.md (97%) rename docs/{ => content}/rules/missing-workspace-mount-folder.md (97%) rename docs/{ => content}/rules/no-app-port.md (97%) rename docs/{ => content}/rules/no-bind-mount.md (97%) rename docs/{ => content}/rules/no-cap-add-all.md (97%) rename docs/{ => content}/rules/no-docker-socket-mount.md (98%) rename docs/{ => content}/rules/no-host-port-format.md (97%) rename docs/{ => content}/rules/no-image-latest.md (96%) rename docs/{ => content}/rules/no-privileged-container.md (98%) rename docs/{ => content}/rules/no-seccomp-override.md (98%) rename docs/{ => content}/rules/no-seccomp-unconfined.md (97%) rename docs/{ => content}/rules/pin-extension-version.md (97%) rename docs/{ => content}/rules/pin-feature-version.md (97%) rename docs/{ => content}/rules/pin-image-digest.md (97%) rename docs/{ => content}/rules/require-cap-drop-all.md (97%) rename docs/{ => content}/rules/require-no-new-privileges.md (97%) rename docs/{ => content}/rules/require-non-root.md (97%) rename docs/{ => content}/rules/undefined-template-option.md (98%) rename docs/{ => content}/rules/unused-template-option.md (98%) create mode 100644 docs/data/categories.yaml create mode 100644 docs/hugo.toml create mode 100644 docs/layouts/baseof.html create mode 100644 docs/layouts/home.html create mode 100644 docs/layouts/page.html create mode 100644 docs/layouts/partials/rule-nav.html create mode 100644 docs/layouts/rules/section.html delete mode 100644 docs/rules/index.md diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index baadb02..e015891 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,10 +7,12 @@ on: branches: [main] paths: - docs/** + - Makefile - .github/workflows/pages.yml pull_request: paths: - docs/** + - Makefile - .github/workflows/pages.yml workflow_dispatch: @@ -22,6 +24,12 @@ concurrency: group: pages cancel-in-progress: false +env: + # renovate: datasource=github-releases depName=gohugoio/hugo + HUGO_VERSION: 0.164.0 + # sha256 of hugo_${HUGO_VERSION}_linux-amd64.tar.gz, from the release's checksums.txt. + HUGO_SHA256: d9c8b17285ea4ec004d9f814273ea910f2051ce02c284993fd1f91ba455ae50d + jobs: build: runs-on: ubuntu-latest @@ -31,13 +39,23 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - # The site's url and baseurl are set in docs/_config.yml rather than taken from the Pages API, - # so the build needs no repository settings and runs the same on a fork. - - uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13 - with: - source: ./docs - destination: ./_site + + # Installed from the release archive, pinned by checksum, rather than through a third-party + # action: one fewer action to trust, and the version is the one the Makefile names. + - name: Install Hugo + run: | + url="https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz" + curl --fail --silent --show-error --location --output hugo.tar.gz "$url" + echo "${HUGO_SHA256} hugo.tar.gz" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/hugo" + tar -xzf hugo.tar.gz -C "$RUNNER_TEMP/hugo" hugo + echo "$RUNNER_TEMP/hugo" >> "$GITHUB_PATH" + + - run: make site + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: docs/public deploy: needs: build diff --git a/.gitignore b/.gitignore index 7d24a76..c93172f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ profile.cov # Build output /bin/ +/docs/public/ +/docs/resources/ +/docs/.hugo_build.lock # Go workspace file go.work diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82fe9da..caf2b71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,14 @@ make lint # golangci-lint make run ARGS="-format=json path/to/dir" ``` +The documentation site in [`docs/`](docs/) is built with +[Hugo](https://gohugo.io/), pinned to the version the Makefile names: + +```console +make site # build into docs/public +make site-serve # serve with live reload +``` + ## Adding a rule Rules are plain Go code. Declare a @@ -75,10 +83,11 @@ rule lands, also add a row for it to the table in ## Documenting a rule -Every rule has a page under [`docs/rules/`](docs/rules/), named after -its ID, which is where the reasoning and the examples live. It is what -`decolint -explain`, the SARIF output, and every code scanning alert -link to, so write it for the user who just hit the finding. +Every rule has a page under [`docs/content/rules/`](docs/content/rules/), +named after its ID, which is where the reasoning and the examples live. +It is what `decolint -explain`, the SARIF output, and every code +scanning alert link to, so write it for the user who just hit the +finding. ````markdown --- @@ -90,8 +99,6 @@ description: >- disallow ... --- -*{{ page.description }}* - ## Why What goes wrong in the configuration this reports, and what to do @@ -132,8 +139,10 @@ Two things to know when writing the examples: `example_verify: false`, and add the rule to `unverifiableExamples` in the test. -Add the new page to the table in -[`docs/rules/index.md`](docs/rules/index.md) too. +The rule index and the sidebar are built from the pages themselves, so +there is no list to update by hand. Preview the site with `make +site-serve`, which needs [Hugo](https://gohugo.io/) at the version the +[Makefile](Makefile) names. When implementing or reviewing rules, consult the Dev Container specification at [containers.dev](https://containers.dev/) to confirm diff --git a/Makefile b/Makefile index bb617de..54ce30d 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,13 @@ export GOEXPERIMENT := jsonv2 # renovate: datasource=github-releases depName=golangci/golangci-lint GOLANGCI_LINT_VERSION := v2.12.2 +# renovate: datasource=github-releases depName=gohugoio/hugo +HUGO_VERSION := v0.164.0 + +# The site is built with whichever hugo is on PATH; HUGO overrides it, e.g. to a downloaded +# release. Its version is pinned in the Pages workflow, which is what publishes the site. +HUGO ?= hugo + VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) REVISION := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GOBUILDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X main.revision=$(REVISION)" @@ -37,9 +44,29 @@ coverage: ## Run tests and open an HTML coverage report lint: ## Run all lint rules go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run +.PHONY: site +site: ## Build the documentation site into docs/public + $(HUGO) --source docs --minify + +.PHONY: site-serve +site-serve: ## Serve the documentation site with live reload + $(HUGO) server --source docs + +.PHONY: site-syntax +site-syntax: ## Regenerate the syntax highlighting stylesheet + @{ \ + echo '/* Chroma syntax highlighting token colours.'; \ + echo ' Generated by `make site-syntax`; edit that target rather than this file. */'; \ + echo; \ + $(HUGO) gen chromastyles --style=github | tail -n +2; \ + echo '@media (prefers-color-scheme: dark) {'; \ + $(HUGO) gen chromastyles --style=github-dark | tail -n +2 | sed 's/^./ &/'; \ + echo '}'; \ + } > docs/assets/css/syntax.css + .PHONY: clean clean: ## Remove build artifacts - rm -rf bin coverage.out coverage.html + rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.hugo_build.lock .PHONY: install install: ## Install the decolint binary to GOPATH/bin diff --git a/docs/_config.yml b/docs/_config.yml deleted file mode 100644 index e4745ec..0000000 --- a/docs/_config.yml +++ /dev/null @@ -1,27 +0,0 @@ -title: decolint -description: A linter for Dev Container configuration files. -url: https://bare-devcontainer.github.io -baseurl: /decolint -theme: minima -permalink: pretty - -plugins: - - jekyll-relative-links - -header_pages: - - getting-started.md - - rules/index.md - -minima: - skin: auto - -defaults: - - scope: - path: "" - values: - layout: page - -exclude: - - Gemfile - - Gemfile.lock - - vendor diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css new file mode 100644 index 0000000..ac48ab4 --- /dev/null +++ b/docs/assets/css/main.css @@ -0,0 +1,243 @@ +/* decolint documentation site. + Colours are declared once as custom properties and re-declared for a dark preference; the + Chroma token colours generated by `make site-syntax` live in syntax.css. */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1f2328; + --fg-muted: #59636e; + --border: #d1d9e0; + --accent: #0969da; + --surface: #f6f8fa; + --content-width: 46rem; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --fg-muted: #9198a1; + --border: #3d444d; + --accent: #4493f8; + --surface: #151b23; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +code, +pre { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.875em; +} + +code { + background: var(--surface); + border-radius: 4px; + padding: 0.15em 0.35em; +} + +pre code { + background: none; + padding: 0; +} + +.highlight pre, +pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + overflow-x: auto; + padding: 0.85rem 1rem; +} + +table { + border-collapse: collapse; + display: block; + overflow-x: auto; + width: 100%; +} + +th, +td { + border: 1px solid var(--border); + padding: 0.4rem 0.7rem; + text-align: left; + vertical-align: top; +} + +th { + background: var(--surface); +} + +/* Header, footer */ + +.site-header { + align-items: baseline; + border-bottom: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + padding: 0.9rem 1.5rem; +} + +.site-title { + color: var(--fg); + font-size: 1.15rem; + font-weight: 600; +} + +.site-header nav { + display: flex; + gap: 1.25rem; +} + +.site-header nav a[aria-current="page"] { + color: var(--fg); + font-weight: 600; +} + +.site-footer { + border-top: 1px solid var(--border); + color: var(--fg-muted); + margin-top: 4rem; + padding: 1.5rem; +} + +/* Layout */ + +.layout { + margin: 0 auto; + max-width: var(--content-width); + padding: 1.5rem; +} + +.layout--with-sidebar { + display: grid; + gap: 2.5rem; + grid-template-columns: 15rem minmax(0, var(--content-width)); + max-width: calc(var(--content-width) + 17.5rem); +} + +main { + min-width: 0; +} + +h1 { + font-size: 1.9rem; + line-height: 1.25; + margin: 0.5rem 0 1rem; +} + +h2 { + border-bottom: 1px solid var(--border); + font-size: 1.3rem; + margin: 2.2rem 0 0.9rem; + padding-bottom: 0.3rem; +} + +h3 { + font-size: 1rem; + margin: 1.5rem 0 0.5rem; +} + +/* Rule pages */ + +.rule-summary { + color: var(--fg-muted); + font-size: 1.05rem; + margin-top: 0; +} + +.rule-meta { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + display: grid; + gap: 0.15rem 1rem; + grid-template-columns: auto 1fr; + margin: 0 0 1.5rem; + padding: 0.8rem 1rem; +} + +.rule-meta dt { + color: var(--fg-muted); + font-size: 0.85rem; +} + +.rule-meta dd { + margin: 0; + font-size: 0.85rem; +} + +.rule-nav { + border-right: 1px solid var(--border); + font-size: 0.9rem; + padding-right: 1.25rem; +} + +.rule-nav__all { + font-weight: 600; +} + +.rule-nav h2 { + border: 0; + color: var(--fg-muted); + font-size: 0.75rem; + letter-spacing: 0.04em; + margin: 1.4rem 0 0.4rem; + padding: 0; + text-transform: uppercase; +} + +.rule-nav ul { + list-style: none; + margin: 0; + padding: 0; +} + +.rule-nav li { + margin: 0.15rem 0; +} + +.rule-nav a { + word-break: break-word; +} + +.rule-nav a[aria-current="page"] { + color: var(--fg); + font-weight: 600; +} + +@media (max-width: 60rem) { + .layout--with-sidebar { + grid-template-columns: minmax(0, 1fr); + } + + .rule-nav { + border-bottom: 1px solid var(--border); + border-right: 0; + padding: 0 0 1rem; + } +} diff --git a/docs/assets/css/syntax.css b/docs/assets/css/syntax.css new file mode 100644 index 0000000..435d2f9 --- /dev/null +++ b/docs/assets/css/syntax.css @@ -0,0 +1,159 @@ +/* Chroma syntax highlighting token colours. + Generated by `make site-syntax`; edit that target rather than this file. */ + + +/* Background */ .bg { background-color:#f7f7f7; } +/* PreWrapper */ .chroma { background-color:#f7f7f7;-webkit-text-size-adjust:none; } +/* Error */ .chroma .err { color:#f6f8fa;background-color:#82071e } +/* LineLink */ .chroma .lnlinks { outline:none;text-decoration:none;color:inherit } +/* LineTableTD */ .chroma .lntd { vertical-align:top;padding:0;margin:0;border:0; } +/* LineTable */ .chroma .lntable { border-spacing:0;padding:0;margin:0;border:0; } +/* LineHighlight */ .chroma .hl { background-color:#dedede } +/* LineNumbersTable */ .chroma .lnt { white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f } +/* LineNumbers */ .chroma .ln { white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f } +/* Line */ .chroma .line { display:flex; } +/* Keyword */ .chroma .k { color:#cf222e } +/* KeywordConstant */ .chroma .kc { color:#cf222e } +/* KeywordDeclaration */ .chroma .kd { color:#cf222e } +/* KeywordNamespace */ .chroma .kn { color:#cf222e } +/* KeywordPseudo */ .chroma .kp { color:#cf222e } +/* KeywordReserved */ .chroma .kr { color:#cf222e } +/* KeywordType */ .chroma .kt { color:#cf222e } +/* NameAttribute */ .chroma .na { color:#1f2328 } +/* NameClass */ .chroma .nc { color:#1f2328 } +/* NameConstant */ .chroma .no { color:#0550ae } +/* NameDecorator */ .chroma .nd { color:#0550ae } +/* NameEntity */ .chroma .ni { color:#6639ba } +/* NameLabel */ .chroma .nl { color:#900;font-weight:bold } +/* NameNamespace */ .chroma .nn { color:#24292e } +/* NameOther */ .chroma .nx { color:#1f2328 } +/* NameTag */ .chroma .nt { color:#0550ae } +/* NameBuiltin */ .chroma .nb { color:#6639ba } +/* NameBuiltinPseudo */ .chroma .bp { color:#6a737d } +/* NameVariable */ .chroma .nv { color:#953800 } +/* NameVariableClass */ .chroma .vc { color:#953800 } +/* NameVariableGlobal */ .chroma .vg { color:#953800 } +/* NameVariableInstance */ .chroma .vi { color:#953800 } +/* NameVariableMagic */ .chroma .vm { color:#953800 } +/* NameFunction */ .chroma .nf { color:#6639ba } +/* NameFunctionMagic */ .chroma .fm { color:#6639ba } +/* LiteralString */ .chroma .s { color:#0a3069 } +/* LiteralStringAffix */ .chroma .sa { color:#0a3069 } +/* LiteralStringBacktick */ .chroma .sb { color:#0a3069 } +/* LiteralStringChar */ .chroma .sc { color:#0a3069 } +/* LiteralStringDelimiter */ .chroma .dl { color:#0a3069 } +/* LiteralStringDoc */ .chroma .sd { color:#0a3069 } +/* LiteralStringDouble */ .chroma .s2 { color:#0a3069 } +/* LiteralStringEscape */ .chroma .se { color:#0a3069 } +/* LiteralStringHeredoc */ .chroma .sh { color:#0a3069 } +/* LiteralStringInterpol */ .chroma .si { color:#0a3069 } +/* LiteralStringOther */ .chroma .sx { color:#0a3069 } +/* LiteralStringRegex */ .chroma .sr { color:#0a3069 } +/* LiteralStringSingle */ .chroma .s1 { color:#0a3069 } +/* LiteralStringSymbol */ .chroma .ss { color:#032f62 } +/* LiteralNumber */ .chroma .m { color:#0550ae } +/* LiteralNumberBin */ .chroma .mb { color:#0550ae } +/* LiteralNumberFloat */ .chroma .mf { color:#0550ae } +/* LiteralNumberHex */ .chroma .mh { color:#0550ae } +/* LiteralNumberInteger */ .chroma .mi { color:#0550ae } +/* LiteralNumberIntegerLong */ .chroma .il { color:#0550ae } +/* LiteralNumberOct */ .chroma .mo { color:#0550ae } +/* Operator */ .chroma .o { color:#0550ae } +/* OperatorWord */ .chroma .ow { color:#0550ae } +/* OperatorReserved */ .chroma .or { color:#0550ae } +/* Punctuation */ .chroma .p { color:#1f2328 } +/* Comment */ .chroma .c { color:#57606a } +/* CommentHashbang */ .chroma .ch { color:#57606a } +/* CommentMultiline */ .chroma .cm { color:#57606a } +/* CommentSingle */ .chroma .c1 { color:#57606a } +/* CommentSpecial */ .chroma .cs { color:#57606a } +/* CommentPreproc */ .chroma .cp { color:#57606a } +/* CommentPreprocFile */ .chroma .cpf { color:#57606a } +/* GenericDeleted */ .chroma .gd { color:#82071e;background-color:#ffebe9 } +/* GenericEmph */ .chroma .ge { color:#1f2328 } +/* GenericInserted */ .chroma .gi { color:#116329;background-color:#dafbe1 } +/* GenericOutput */ .chroma .go { color:#1f2328 } +/* GenericUnderline */ .chroma .gl { text-decoration:underline } +/* TextWhitespace */ .chroma .w { color:#fff } +@media (prefers-color-scheme: dark) { + + /* Background */ .bg { color:#e6edf3;background-color:#0d1117; } + /* PreWrapper */ .chroma { color:#e6edf3;background-color:#0d1117;-webkit-text-size-adjust:none; } + /* Error */ .chroma .err { color:#f85149 } + /* LineLink */ .chroma .lnlinks { outline:none;text-decoration:none;color:inherit } + /* LineTableTD */ .chroma .lntd { vertical-align:top;padding:0;margin:0;border:0; } + /* LineTable */ .chroma .lntable { border-spacing:0;padding:0;margin:0;border:0; } + /* LineHighlight */ .chroma .hl { background-color:#6e7681 } + /* LineNumbersTable */ .chroma .lnt { white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679 } + /* LineNumbers */ .chroma .ln { white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681 } + /* Line */ .chroma .line { display:flex; } + /* Keyword */ .chroma .k { color:#ff7b72 } + /* KeywordConstant */ .chroma .kc { color:#79c0ff } + /* KeywordDeclaration */ .chroma .kd { color:#ff7b72 } + /* KeywordNamespace */ .chroma .kn { color:#ff7b72 } + /* KeywordPseudo */ .chroma .kp { color:#79c0ff } + /* KeywordReserved */ .chroma .kr { color:#ff7b72 } + /* KeywordType */ .chroma .kt { color:#ff7b72 } + /* NameClass */ .chroma .nc { color:#f0883e;font-weight:bold } + /* NameConstant */ .chroma .no { color:#79c0ff;font-weight:bold } + /* NameDecorator */ .chroma .nd { color:#d2a8ff;font-weight:bold } + /* NameEntity */ .chroma .ni { color:#ffa657 } + /* NameException */ .chroma .ne { color:#f0883e;font-weight:bold } + /* NameLabel */ .chroma .nl { color:#79c0ff;font-weight:bold } + /* NameNamespace */ .chroma .nn { color:#ff7b72 } + /* NameOther */ .chroma .nx { color:#e6edf3 } + /* NameProperty */ .chroma .py { color:#79c0ff } + /* NameTag */ .chroma .nt { color:#7ee787 } + /* NameVariable */ .chroma .nv { color:#79c0ff } + /* NameVariableClass */ .chroma .vc { color:#79c0ff } + /* NameVariableGlobal */ .chroma .vg { color:#79c0ff } + /* NameVariableInstance */ .chroma .vi { color:#79c0ff } + /* NameVariableMagic */ .chroma .vm { color:#79c0ff } + /* NameFunction */ .chroma .nf { color:#d2a8ff;font-weight:bold } + /* NameFunctionMagic */ .chroma .fm { color:#d2a8ff;font-weight:bold } + /* Literal */ .chroma .l { color:#a5d6ff } + /* LiteralDate */ .chroma .ld { color:#79c0ff } + /* LiteralString */ .chroma .s { color:#a5d6ff } + /* LiteralStringAffix */ .chroma .sa { color:#79c0ff } + /* LiteralStringBacktick */ .chroma .sb { color:#a5d6ff } + /* LiteralStringChar */ .chroma .sc { color:#a5d6ff } + /* LiteralStringDelimiter */ .chroma .dl { color:#79c0ff } + /* LiteralStringDoc */ .chroma .sd { color:#a5d6ff } + /* LiteralStringDouble */ .chroma .s2 { color:#a5d6ff } + /* LiteralStringEscape */ .chroma .se { color:#79c0ff } + /* LiteralStringHeredoc */ .chroma .sh { color:#79c0ff } + /* LiteralStringInterpol */ .chroma .si { color:#a5d6ff } + /* LiteralStringOther */ .chroma .sx { color:#a5d6ff } + /* LiteralStringRegex */ .chroma .sr { color:#79c0ff } + /* LiteralStringSingle */ .chroma .s1 { color:#a5d6ff } + /* LiteralStringSymbol */ .chroma .ss { color:#a5d6ff } + /* LiteralNumber */ .chroma .m { color:#a5d6ff } + /* LiteralNumberBin */ .chroma .mb { color:#a5d6ff } + /* LiteralNumberFloat */ .chroma .mf { color:#a5d6ff } + /* LiteralNumberHex */ .chroma .mh { color:#a5d6ff } + /* LiteralNumberInteger */ .chroma .mi { color:#a5d6ff } + /* LiteralNumberIntegerLong */ .chroma .il { color:#a5d6ff } + /* LiteralNumberOct */ .chroma .mo { color:#a5d6ff } + /* Operator */ .chroma .o { color:#ff7b72;font-weight:bold } + /* OperatorWord */ .chroma .ow { color:#ff7b72;font-weight:bold } + /* OperatorReserved */ .chroma .or { color:#ff7b72;font-weight:bold } + /* Comment */ .chroma .c { color:#8b949e;font-style:italic } + /* CommentHashbang */ .chroma .ch { color:#8b949e;font-style:italic } + /* CommentMultiline */ .chroma .cm { color:#8b949e;font-style:italic } + /* CommentSingle */ .chroma .c1 { color:#8b949e;font-style:italic } + /* CommentSpecial */ .chroma .cs { color:#8b949e;font-weight:bold;font-style:italic } + /* CommentPreproc */ .chroma .cp { color:#8b949e;font-weight:bold;font-style:italic } + /* CommentPreprocFile */ .chroma .cpf { color:#8b949e;font-weight:bold;font-style:italic } + /* GenericDeleted */ .chroma .gd { color:#ffa198;background-color:#490202 } + /* GenericEmph */ .chroma .ge { font-style:italic } + /* GenericError */ .chroma .gr { color:#ffa198 } + /* GenericHeading */ .chroma .gh { color:#79c0ff;font-weight:bold } + /* GenericInserted */ .chroma .gi { color:#56d364;background-color:#0f5323 } + /* GenericOutput */ .chroma .go { color:#8b949e } + /* GenericPrompt */ .chroma .gp { color:#8b949e } + /* GenericStrong */ .chroma .gs { font-weight:bold } + /* GenericSubheading */ .chroma .gu { color:#79c0ff } + /* GenericTraceback */ .chroma .gt { color:#ff7b72 } + /* GenericUnderline */ .chroma .gl { text-decoration:underline } + /* TextWhitespace */ .chroma .w { color:#6e7681 } +} diff --git a/docs/index.md b/docs/content/_index.md similarity index 100% rename from docs/index.md rename to docs/content/_index.md diff --git a/docs/getting-started.md b/docs/content/getting-started.md similarity index 100% rename from docs/getting-started.md rename to docs/content/getting-started.md diff --git a/docs/content/rules/_index.md b/docs/content/rules/_index.md new file mode 100644 index 0000000..7405d9a --- /dev/null +++ b/docs/content/rules/_index.md @@ -0,0 +1,10 @@ +--- +title: Rules +description: >- + Every rule decolint ships, what it checks, and why. +--- + +Each rule belongs to one category, which sets its severity unless a +[config file](../getting-started.md#choosing-what-to-report) overrides it; only +`correctness` is enabled out of the box. A rule that names a platform runs only +when that platform is selected with `-platform`. diff --git a/docs/rules/conflicting-container-def.md b/docs/content/rules/conflicting-container-def.md similarity index 97% rename from docs/rules/conflicting-container-def.md rename to docs/content/rules/conflicting-container-def.md index 0aa532d..cb58f41 100644 --- a/docs/rules/conflicting-container-def.md +++ b/docs/content/rules/conflicting-container-def.md @@ -8,8 +8,6 @@ description: >- "build", or "dockerComposeFile" --- -*{{ page.description }}* - ## Why The specification defines three mutually exclusive ways to create the container: from an image, from a diff --git a/docs/rules/feature-install-script-not-executable.md b/docs/content/rules/feature-install-script-not-executable.md similarity index 97% rename from docs/rules/feature-install-script-not-executable.md rename to docs/content/rules/feature-install-script-not-executable.md index 50ab52a..51b3458 100644 --- a/docs/rules/feature-install-script-not-executable.md +++ b/docs/content/rules/feature-install-script-not-executable.md @@ -8,8 +8,6 @@ description: >- example_verify: false --- -*{{ page.description }}* - ## Why The specification has the installing tool invoke "install.sh" directly rather than through a shell, so diff --git a/docs/rules/id-dir-mismatch.md b/docs/content/rules/id-dir-mismatch.md similarity index 97% rename from docs/rules/id-dir-mismatch.md rename to docs/content/rules/id-dir-mismatch.md index 34b2233..4000dfc 100644 --- a/docs/rules/id-dir-mismatch.md +++ b/docs/content/rules/id-dir-mismatch.md @@ -9,8 +9,6 @@ description: >- example_dir: node --- -*{{ page.description }}* - ## Why Both specifications require the "id" to match the name of the directory holding the metadata file, since diff --git a/docs/rules/invalid-semver.md b/docs/content/rules/invalid-semver.md similarity index 96% rename from docs/rules/invalid-semver.md rename to docs/content/rules/invalid-semver.md index 7fede71..79febb7 100644 --- a/docs/rules/invalid-semver.md +++ b/docs/content/rules/invalid-semver.md @@ -8,8 +8,6 @@ description: >- version --- -*{{ page.description }}* - ## Why Publishing a Feature or Template pushes it under tags derived from the "version" components: the full diff --git a/docs/rules/missing-build-dockerfile.md b/docs/content/rules/missing-build-dockerfile.md similarity index 97% rename from docs/rules/missing-build-dockerfile.md rename to docs/content/rules/missing-build-dockerfile.md index 0e168f5..90900aa 100644 --- a/docs/rules/missing-build-dockerfile.md +++ b/docs/content/rules/missing-build-dockerfile.md @@ -7,8 +7,6 @@ description: >- disallow a devcontainer.json "build" object that is missing "dockerfile" --- -*{{ page.description }}* - ## Why "build.dockerfile" is the only required member of "build": it locates, relative to the devcontainer.json, diff --git a/docs/rules/missing-compose-service.md b/docs/content/rules/missing-compose-service.md similarity index 97% rename from docs/rules/missing-compose-service.md rename to docs/content/rules/missing-compose-service.md index 8acae4d..d8219b6 100644 --- a/docs/rules/missing-compose-service.md +++ b/docs/content/rules/missing-compose-service.md @@ -8,8 +8,6 @@ description: >- "service" --- -*{{ page.description }}* - ## Why A Compose project usually defines several services, so naming the Compose file does not say which diff --git a/docs/rules/missing-container-def.md b/docs/content/rules/missing-container-def.md similarity index 97% rename from docs/rules/missing-container-def.md rename to docs/content/rules/missing-container-def.md index b6bee5e..77d956a 100644 --- a/docs/rules/missing-container-def.md +++ b/docs/content/rules/missing-container-def.md @@ -8,8 +8,6 @@ description: >- "dockerComposeFile" --- -*{{ page.description }}* - ## Why Every dev container is created from exactly one of "image", "build", or "dockerComposeFile", and each of diff --git a/docs/rules/missing-feature-install-script.md b/docs/content/rules/missing-feature-install-script.md similarity index 97% rename from docs/rules/missing-feature-install-script.md rename to docs/content/rules/missing-feature-install-script.md index 5410b0b..977e81d 100644 --- a/docs/rules/missing-feature-install-script.md +++ b/docs/content/rules/missing-feature-install-script.md @@ -9,8 +9,6 @@ description: >- example_verify: false --- -*{{ page.description }}* - ## Why A Feature is distributed as its metadata file plus the "install.sh" the tooling runs inside the container, diff --git a/docs/rules/missing-required-props.md b/docs/content/rules/missing-required-props.md similarity index 97% rename from docs/rules/missing-required-props.md rename to docs/content/rules/missing-required-props.md index 92da134..5a4fbdc 100644 --- a/docs/rules/missing-required-props.md +++ b/docs/content/rules/missing-required-props.md @@ -8,8 +8,6 @@ description: >- property ("id", "version", or "name") --- -*{{ page.description }}* - ## Why "id", "version", and "name" are the only properties either specification requires: the "id" addresses the diff --git a/docs/rules/missing-workspace-mount-folder.md b/docs/content/rules/missing-workspace-mount-folder.md similarity index 97% rename from docs/rules/missing-workspace-mount-folder.md rename to docs/content/rules/missing-workspace-mount-folder.md index 237176f..f854eb4 100644 --- a/docs/rules/missing-workspace-mount-folder.md +++ b/docs/content/rules/missing-workspace-mount-folder.md @@ -8,8 +8,6 @@ description: >- of "workspaceMount" or "workspaceFolder" --- -*{{ page.description }}* - ## Why The two properties describe opposite ends of the same override: "workspaceMount" says where the source diff --git a/docs/rules/no-app-port.md b/docs/content/rules/no-app-port.md similarity index 97% rename from docs/rules/no-app-port.md rename to docs/content/rules/no-app-port.md index b795048..c75a3b1 100644 --- a/docs/rules/no-app-port.md +++ b/docs/content/rules/no-app-port.md @@ -7,8 +7,6 @@ description: >- disallow the legacy "appPort" property in favor of "forwardPorts" --- -*{{ page.description }}* - ## Why "appPort" publishes the port the way Docker does: it is fixed when the container is created, and the diff --git a/docs/rules/no-bind-mount.md b/docs/content/rules/no-bind-mount.md similarity index 97% rename from docs/rules/no-bind-mount.md rename to docs/content/rules/no-bind-mount.md index b239451..c2ab9b5 100644 --- a/docs/rules/no-bind-mount.md +++ b/docs/content/rules/no-bind-mount.md @@ -8,8 +8,6 @@ description: >- ignores except for the Docker socket --- -*{{ page.description }}* - ## Why A codespace runs on a machine in the cloud, where the host path a bind mount points at does not exist, so diff --git a/docs/rules/no-cap-add-all.md b/docs/content/rules/no-cap-add-all.md similarity index 97% rename from docs/rules/no-cap-add-all.md rename to docs/content/rules/no-cap-add-all.md index 822e378..8facbcf 100644 --- a/docs/rules/no-cap-add-all.md +++ b/docs/content/rules/no-cap-add-all.md @@ -9,8 +9,6 @@ description: >- "runArgs" --- -*{{ page.description }}* - ## Why Linux capabilities split root's powers into units a container can be granted individually, and the runtime diff --git a/docs/rules/no-docker-socket-mount.md b/docs/content/rules/no-docker-socket-mount.md similarity index 98% rename from docs/rules/no-docker-socket-mount.md rename to docs/content/rules/no-docker-socket-mount.md index d07d224..69384ee 100644 --- a/docs/rules/no-docker-socket-mount.md +++ b/docs/content/rules/no-docker-socket-mount.md @@ -9,8 +9,6 @@ description: >- over the host --- -*{{ page.description }}* - ## Why The Docker socket is the daemon's full API, and the daemon runs as root on the host. Anything that can diff --git a/docs/rules/no-host-port-format.md b/docs/content/rules/no-host-port-format.md similarity index 97% rename from docs/rules/no-host-port-format.md rename to docs/content/rules/no-host-port-format.md index 4709b08..84f4ba6 100644 --- a/docs/rules/no-host-port-format.md +++ b/docs/content/rules/no-host-port-format.md @@ -8,8 +8,6 @@ description: >- which GitHub Codespaces does not support --- -*{{ page.description }}* - ## Why The "host:port" form forwards a port from another container in a Docker Compose project (e.g. "db:5432") diff --git a/docs/rules/no-image-latest.md b/docs/content/rules/no-image-latest.md similarity index 96% rename from docs/rules/no-image-latest.md rename to docs/content/rules/no-image-latest.md index 57eb91e..301f80c 100644 --- a/docs/rules/no-image-latest.md +++ b/docs/content/rules/no-image-latest.md @@ -7,8 +7,6 @@ description: >- disallow container images without an explicit tag or with the "latest" tag --- -*{{ page.description }}* - ## Why A reference with no tag resolves to "latest", and "latest" is just the tag a publisher moves as they diff --git a/docs/rules/no-privileged-container.md b/docs/content/rules/no-privileged-container.md similarity index 98% rename from docs/rules/no-privileged-container.md rename to docs/content/rules/no-privileged-container.md index d5b0732..4bdbf5c 100644 --- a/docs/rules/no-privileged-container.md +++ b/docs/content/rules/no-privileged-container.md @@ -8,8 +8,6 @@ description: >- property or a "--privileged" entry in "runArgs" --- -*{{ page.description }}* - ## Why A privileged container gets every Linux capability, unconfined seccomp and LSM profiles, and access to all diff --git a/docs/rules/no-seccomp-override.md b/docs/content/rules/no-seccomp-override.md similarity index 98% rename from docs/rules/no-seccomp-override.md rename to docs/content/rules/no-seccomp-override.md index f58bf2e..c6606cb 100644 --- a/docs/rules/no-seccomp-override.md +++ b/docs/content/rules/no-seccomp-override.md @@ -9,8 +9,6 @@ description: >- "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs" --- -*{{ page.description }}* - ## Why The runtime's default seccomp profile blocks the syscalls containers do not need, several of which have diff --git a/docs/rules/no-seccomp-unconfined.md b/docs/content/rules/no-seccomp-unconfined.md similarity index 97% rename from docs/rules/no-seccomp-unconfined.md rename to docs/content/rules/no-seccomp-unconfined.md index 1bc3acb..712aa8b 100644 --- a/docs/rules/no-seccomp-unconfined.md +++ b/docs/content/rules/no-seccomp-unconfined.md @@ -9,8 +9,6 @@ description: >- entry in a devcontainer.json's "runArgs" --- -*{{ page.description }}* - ## Why "seccomp=unconfined" turns off syscall filtering entirely, exposing the whole kernel API — including the diff --git a/docs/rules/pin-extension-version.md b/docs/content/rules/pin-extension-version.md similarity index 97% rename from docs/rules/pin-extension-version.md rename to docs/content/rules/pin-extension-version.md index c87e4d1..1eb5d35 100644 --- a/docs/rules/pin-extension-version.md +++ b/docs/content/rules/pin-extension-version.md @@ -8,8 +8,6 @@ description: >- pinned version --- -*{{ page.description }}* - ## Why An extension ID on its own installs whatever the marketplace publishes at the moment the container is diff --git a/docs/rules/pin-feature-version.md b/docs/content/rules/pin-feature-version.md similarity index 97% rename from docs/rules/pin-feature-version.md rename to docs/content/rules/pin-feature-version.md index 01c3bde..4f761fb 100644 --- a/docs/rules/pin-feature-version.md +++ b/docs/content/rules/pin-feature-version.md @@ -8,8 +8,6 @@ description: >- "latest" version --- -*{{ page.description }}* - ## Why A Feature reference with no version resolves to "latest", so the container installs whatever the Feature's diff --git a/docs/rules/pin-image-digest.md b/docs/content/rules/pin-image-digest.md similarity index 97% rename from docs/rules/pin-image-digest.md rename to docs/content/rules/pin-image-digest.md index 2e57075..6fb9353 100644 --- a/docs/rules/pin-image-digest.md +++ b/docs/content/rules/pin-image-digest.md @@ -8,8 +8,6 @@ description: >- (e.g. "image@sha256:...") --- -*{{ page.description }}* - ## Why A tag is a mutable pointer: the publisher can move even a fully specified one to different bits, and a diff --git a/docs/rules/require-cap-drop-all.md b/docs/content/rules/require-cap-drop-all.md similarity index 97% rename from docs/rules/require-cap-drop-all.md rename to docs/content/rules/require-cap-drop-all.md index 89742a8..f7de108 100644 --- a/docs/rules/require-cap-drop-all.md +++ b/docs/content/rules/require-cap-drop-all.md @@ -8,8 +8,6 @@ description: >- dropping every Linux capability --- -*{{ page.description }}* - ## Why Container runtimes grant a default set of capabilities that a dev container almost never uses: raw network diff --git a/docs/rules/require-no-new-privileges.md b/docs/content/rules/require-no-new-privileges.md similarity index 97% rename from docs/rules/require-no-new-privileges.md rename to docs/content/rules/require-no-new-privileges.md index 720583c..f2578b1 100644 --- a/docs/rules/require-no-new-privileges.md +++ b/docs/content/rules/require-no-new-privileges.md @@ -9,8 +9,6 @@ description: >- in "runArgs" --- -*{{ page.description }}* - ## Why Without this option a process in the container can still gain privileges it was not started with, by diff --git a/docs/rules/require-non-root.md b/docs/content/rules/require-non-root.md similarity index 97% rename from docs/rules/require-non-root.md rename to docs/content/rules/require-non-root.md index 74adfbc..1bab85c 100644 --- a/docs/rules/require-non-root.md +++ b/docs/content/rules/require-non-root.md @@ -8,8 +8,6 @@ description: >- user --- -*{{ page.description }}* - ## Why "remoteUser" defaults to whatever user the container runs as, which for most images is root. Everything diff --git a/docs/rules/undefined-template-option.md b/docs/content/rules/undefined-template-option.md similarity index 98% rename from docs/rules/undefined-template-option.md rename to docs/content/rules/undefined-template-option.md index 0f1bf1d..66d4ec0 100644 --- a/docs/rules/undefined-template-option.md +++ b/docs/content/rules/undefined-template-option.md @@ -9,8 +9,6 @@ description: >- example_dir: dotnet --- -*{{ page.description }}* - ## Why Applying a Template replaces each "${templateOption:name}" with the value the user chose for the option of diff --git a/docs/rules/unused-template-option.md b/docs/content/rules/unused-template-option.md similarity index 98% rename from docs/rules/unused-template-option.md rename to docs/content/rules/unused-template-option.md index 97536ca..d5ed7a1 100644 --- a/docs/rules/unused-template-option.md +++ b/docs/content/rules/unused-template-option.md @@ -8,8 +8,6 @@ description: >- example_dir: dotnet --- -*{{ page.description }}* - ## Why An option only takes effect where a file substitutes it as "${templateOption:name}". One that nothing diff --git a/docs/data/categories.yaml b/docs/data/categories.yaml new file mode 100644 index 0000000..a8e193c --- /dev/null +++ b/docs/data/categories.yaml @@ -0,0 +1,14 @@ +# The categories a rule can belong to, in the order the rule reference presents them, which is +# decreasing order of how much a project is likely to want the category enabled. Each name must +# match a linter.Category name, which the rule pages carry in their front matter. +- name: correctness + blurb: >- + The configuration is invalid or does not behave as written. These run by + default, at `error`. +- name: security + blurb: Container runtime privileges and hardening. +- name: reproducibility + blurb: >- + Unpinned versions or digests that let the environment drift between builds. +- name: style + blurb: Discouraged or legacy configuration that still works. diff --git a/docs/hugo.toml b/docs/hugo.toml new file mode 100644 index 0000000..4462ac2 --- /dev/null +++ b/docs/hugo.toml @@ -0,0 +1,45 @@ +baseURL = "https://bare-devcontainer.github.io/decolint/" +title = "decolint" +locale = "en-us" +enableRobotsTXT = true + +# A rule reference has no feed, tags or categories pages; the only taxonomy is the category each +# rule already carries in its front matter, which the rules section renders itself. +disableKinds = ["taxonomy", "term", "RSS"] + +# Findings link to a rule's page by an address derived from its ID (see rules.DocsURL), so the +# published paths have to stay /rules//. +[permalinks] + rules = "/rules/:slug/" + +[params] + description = "A linter for Dev Container configuration files." + repository = "https://github.com/bare-devcontainer/decolint" + +[markup.highlight] + # Emit token classes rather than inline styles, so the colours live in the site's own stylesheet + # and follow the reader's light or dark preference. + noClasses = false + +# Resolve links written as relative Markdown paths, e.g. [Rules](rules/no-app-port.md), to the +# published address of the page. Pages then read correctly both on the site and on GitHub. +[markup.goldmark.renderHooks.link] + useEmbedded = "fallback" + +[markup.goldmark.renderer] + # Rule pages are written by contributors and rendered as-is; nothing needs raw HTML. + unsafe = false + +[markup.tableOfContents] + startLevel = 2 + endLevel = 2 + +[[menus.main]] + name = "Getting started" + pageRef = "/getting-started" + weight = 10 + +[[menus.main]] + name = "Rules" + pageRef = "/rules" + weight = 20 diff --git a/docs/layouts/baseof.html b/docs/layouts/baseof.html new file mode 100644 index 0000000..99c937b --- /dev/null +++ b/docs/layouts/baseof.html @@ -0,0 +1,37 @@ + + + + + + {{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} — {{ site.Title }}{{ end }} + + {{ with .OutputFormats.Get "html" }}{{ end }} + {{ range slice "css/main.css" "css/syntax.css" }} + {{ with resources.Get . | minify | fingerprint }} + + {{ end }} + {{ end }} + + + + +
+ {{ if eq .Section "rules" }}{{ partial "rule-nav.html" . }}{{ end }} +
+ {{ block "main" . }}{{ end }} +
+
+ + + + diff --git a/docs/layouts/home.html b/docs/layouts/home.html new file mode 100644 index 0000000..4cfd224 --- /dev/null +++ b/docs/layouts/home.html @@ -0,0 +1,4 @@ +{{ define "main" }} +

{{ .Title }}

+ {{ .Content }} +{{ end }} diff --git a/docs/layouts/page.html b/docs/layouts/page.html new file mode 100644 index 0000000..a94a294 --- /dev/null +++ b/docs/layouts/page.html @@ -0,0 +1,19 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{/* A rule page states what the rule checks, and how it is scoped, from its front matter; the + body picks up at the reasoning. */}} + {{ if eq .Section "rules" }} +

{{ .RenderString .Description }}

+
+
Category
+
{{ .Params.category }}
+
Applies to
+
{{ delimit .Params.file_types ", " }}
+
Platforms
+
{{ with .Params.platforms }}{{ delimit . ", " }}{{ else }}all{{ end }}
+
+ {{ end }} + {{ .Content }} +
+{{ end }} diff --git a/docs/layouts/partials/rule-nav.html b/docs/layouts/partials/rule-nav.html new file mode 100644 index 0000000..f3fc86d --- /dev/null +++ b/docs/layouts/partials/rule-nav.html @@ -0,0 +1,17 @@ +{{/* The sidebar shown alongside every rule page: each category, then the rules in it. It is built +from the pages themselves, so it lists exactly what the site publishes. */}} +{{ $rules := where site.RegularPages "Section" "rules" }} + diff --git a/docs/layouts/rules/section.html b/docs/layouts/rules/section.html new file mode 100644 index 0000000..6a1e358 --- /dev/null +++ b/docs/layouts/rules/section.html @@ -0,0 +1,30 @@ +{{ define "main" }} +

{{ .Title }}

+ {{ .Content }} + + {{/* The table is built from the rule pages themselves, so it cannot fall behind them. That the + pages in turn match the rules decolint registers is what rules/doc_test.go checks. */}} + {{ $rules := where site.RegularPages "Section" "rules" }} + {{ range hugo.Data.categories }} + {{ $category := .name }} + {{ $blurb := .blurb }} + {{ with where $rules "Params.category" $category }} +

{{ $category }}

+

{{ $.RenderString $blurb }}

+ + + + + + {{ range sort . "Title" }} + + + + + + {{ end }} + +
RulePlatformChecks
{{ .Title }}{{ with .Params.platforms }}{{ delimit . ", " }}{{ else }}all{{ end }}{{ $.RenderString .Description }}
+ {{ end }} + {{ end }} +{{ end }} diff --git a/docs/rules/index.md b/docs/rules/index.md deleted file mode 100644 index cf311ae..0000000 --- a/docs/rules/index.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Rules ---- - -decolint ships 27 rules. Each belongs to one category, which sets its severity -unless a [config file](../getting-started.md#choosing-what-to-report) overrides -it; only `correctness` is enabled out of the box. A rule that names a platform -runs only when that platform is selected with `-platform`. - -## correctness - -The configuration is invalid or does not behave as written. These run by default, at `error`. - -| Rule | Platform | Checks | -| --- | --- | --- | -| [`conflicting-container-def`](conflicting-container-def.md) | (all) | disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile" | -| [`feature-install-script-not-executable`](feature-install-script-not-executable.md) | (all) | disallow a Feature's `install.sh` that lacks executable permission bits | -| [`id-dir-mismatch`](id-dir-mismatch.md) | (all) | disallow a Feature's or Template's "id" that does not match the name of its containing directory | -| [`invalid-semver`](invalid-semver.md) | (all) | disallow a Feature's or Template's "version" that is not a valid semantic version | -| [`missing-build-dockerfile`](missing-build-dockerfile.md) | (all) | disallow a devcontainer.json "build" object that is missing "dockerfile" | -| [`missing-compose-service`](missing-compose-service.md) | (all) | disallow a devcontainer.json that sets "dockerComposeFile" without "service" | -| [`missing-container-def`](missing-container-def.md) | (all) | disallow a devcontainer.json that defines none of "image", "build", or "dockerComposeFile" | -| [`missing-feature-install-script`](missing-feature-install-script.md) | (all) | disallow a Feature directory without the required `install.sh` install script | -| [`missing-required-props`](missing-required-props.md) | (all) | disallow a Feature's or Template's metadata that is missing a required property ("id", "version", or "name") | -| [`missing-workspace-mount-folder`](missing-workspace-mount-folder.md) | (all) | disallow a devcontainer.json using "image" or "build" that sets only one of "workspaceMount" or "workspaceFolder" | -| [`no-bind-mount`](no-bind-mount.md) | `codespaces` | disallow "bind" type entries in "mounts", which GitHub Codespaces silently ignores except for the Docker socket | -| [`no-host-port-format`](no-host-port-format.md) | `codespaces` | disallow "host:port" entries in "forwardPorts" and "portsAttributes", which GitHub Codespaces does not support | -| [`undefined-template-option`](undefined-template-option.md) | (all) | disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json | - -## security - -Container runtime privileges and hardening. - -| Rule | Platform | Checks | -| --- | --- | --- | -| [`no-cap-add-all`](no-cap-add-all.md) | (all) | disallow granting all Linux capabilities via an "ALL" entry in the "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's "runArgs" | -| [`no-docker-socket-mount`](no-docker-socket-mount.md) | (all) | disallow bind-mounting the host's Docker socket via a devcontainer.json's "mounts" or "runArgs", which grants the container root-equivalent control over the host | -| [`no-privileged-container`](no-privileged-container.md) | (all) | disallow running the container in privileged mode via the "privileged" property or a "--privileged" entry in "runArgs" | -| [`no-seccomp-override`](no-seccomp-override.md) | (all) | disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs" | -| [`no-seccomp-unconfined`](no-seccomp-unconfined.md) | (all) | disallow disabling seccomp confinement via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" entry in a devcontainer.json's "runArgs" | -| [`require-cap-drop-all`](require-cap-drop-all.md) | (all) | require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", dropping every Linux capability | -| [`require-no-new-privileges`](require-no-new-privileges.md) | (all) | require "no-new-privileges" to be set via a devcontainer.json's "securityOpt" property, or a "--security-opt no-new-privileges..." entry in "runArgs" | -| [`require-non-root`](require-non-root.md) | (all) | require "remoteUser" or, if unset, "containerUser" to be set to a non-root user | - -## reproducibility - -Unpinned versions or digests that let the environment drift between builds. - -| Rule | Platform | Checks | -| --- | --- | --- | -| [`no-image-latest`](no-image-latest.md) | (all) | disallow container images without an explicit tag or with the "latest" tag | -| [`pin-extension-version`](pin-extension-version.md) | `vscode`, `codespaces` | disallow a "customizations.vscode.extensions" entry without an explicit pinned version | -| [`pin-feature-version`](pin-feature-version.md) | (all) | disallow a Feature reference without an explicit version or with the "latest" version | -| [`pin-image-digest`](pin-image-digest.md) | (all) | disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...") | - -## style - -Discouraged or legacy configuration that still works. - -| Rule | Platform | Checks | -| --- | --- | --- | -| [`no-app-port`](no-app-port.md) | (all) | disallow the legacy "appPort" property in favor of "forwardPorts" | -| [`unused-template-option`](unused-template-option.md) | (all) | disallow a Template option that no file in the Template references | diff --git a/rules/doc_test.go b/rules/doc_test.go index 7cb6b86..f4a0dd8 100644 --- a/rules/doc_test.go +++ b/rules/doc_test.go @@ -21,7 +21,7 @@ import ( // behavior the rule does not have. // docsDir holds the rule reference, one Markdown page per rule ID, relative to this package. -const docsDir = "../docs/rules" +const docsDir = "../docs/content/rules" // docsFileNames maps a file type to the name a page's examples are linted under, which is the name // the specification gives that kind of configuration file. @@ -197,7 +197,8 @@ func readDocsPages(t *testing.T) map[string]docsPage { pages := make(map[string]docsPage, len(entries)) for _, e := range entries { name := e.Name() - if e.IsDir() || filepath.Ext(name) != ".md" || name == "index.md" { + // A leading underscore marks the section's own list page, not a rule. + if e.IsDir() || filepath.Ext(name) != ".md" || strings.HasPrefix(name, "_") { continue } src, err := os.ReadFile(filepath.Join(docsDir, name)) From e38066addede0544a47587111de9d6aa7d321ec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 00:51:19 +0000 Subject: [PATCH 05/24] refactor: move discovery, format and substitute back to the module root Nesting them under cmd/decolint put library packages in the directory that holds a binary's entry point, and gave them import paths like decolint/cmd/decolint/format: still importable from outside the module, while sitting where nobody looks for a package. This restores the layout main has. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- cmd/decolint/format.go | 2 +- cmd/decolint/format_test.go | 2 +- cmd/decolint/main.go | 4 ++-- cmd/decolint/main_test.go | 2 +- {cmd/decolint/discovery => discovery}/discovery.go | 0 {cmd/decolint/discovery => discovery}/discovery_test.go | 0 .../testdata/feature/devcontainer-feature.json | 0 .../testdata/project/.devcontainer/devcontainer.json | 0 .../testdata/project/.devcontainer/go/devcontainer.json | 0 .../testdata/rootfile/.devcontainer.json | 0 .../template-no-devcontainer/devcontainer-template.json | 0 .../testdata/template-rootfile/.devcontainer.json | 0 .../testdata/template-rootfile/devcontainer-template.json | 0 .../template-subfolder/.devcontainer/go/devcontainer.json | 0 .../testdata/template-subfolder/devcontainer-template.json | 0 .../testdata/template/.devcontainer/devcontainer.json | 0 .../testdata/template/devcontainer-template.json | 0 {cmd/decolint/format => format}/doc.go | 0 {cmd/decolint/format => format}/github.go | 0 {cmd/decolint/format => format}/github_test.go | 0 {cmd/decolint/format => format}/json.go | 0 {cmd/decolint/format => format}/json_test.go | 0 {cmd/decolint/format => format}/sarif.go | 0 {cmd/decolint/format => format}/sarif_test.go | 0 {cmd/decolint/format => format}/text.go | 0 {cmd/decolint/format => format}/text_test.go | 0 {cmd/decolint/substitute => substitute}/substitute.go | 0 {cmd/decolint/substitute => substitute}/substitute_test.go | 0 28 files changed, 5 insertions(+), 5 deletions(-) rename {cmd/decolint/discovery => discovery}/discovery.go (100%) rename {cmd/decolint/discovery => discovery}/discovery_test.go (100%) rename {cmd/decolint/discovery => discovery}/testdata/feature/devcontainer-feature.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/project/.devcontainer/devcontainer.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/project/.devcontainer/go/devcontainer.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/rootfile/.devcontainer.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template-no-devcontainer/devcontainer-template.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template-rootfile/.devcontainer.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template-rootfile/devcontainer-template.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template-subfolder/.devcontainer/go/devcontainer.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template-subfolder/devcontainer-template.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template/.devcontainer/devcontainer.json (100%) rename {cmd/decolint/discovery => discovery}/testdata/template/devcontainer-template.json (100%) rename {cmd/decolint/format => format}/doc.go (100%) rename {cmd/decolint/format => format}/github.go (100%) rename {cmd/decolint/format => format}/github_test.go (100%) rename {cmd/decolint/format => format}/json.go (100%) rename {cmd/decolint/format => format}/json_test.go (100%) rename {cmd/decolint/format => format}/sarif.go (100%) rename {cmd/decolint/format => format}/sarif_test.go (100%) rename {cmd/decolint/format => format}/text.go (100%) rename {cmd/decolint/format => format}/text_test.go (100%) rename {cmd/decolint/substitute => substitute}/substitute.go (100%) rename {cmd/decolint/substitute => substitute}/substitute_test.go (100%) diff --git a/cmd/decolint/format.go b/cmd/decolint/format.go index 6d083bf..8bee211 100644 --- a/cmd/decolint/format.go +++ b/cmd/decolint/format.go @@ -5,7 +5,7 @@ import ( "io" "strings" - "github.com/bare-devcontainer/decolint/cmd/decolint/format" + "github.com/bare-devcontainer/decolint/format" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" ) diff --git a/cmd/decolint/format_test.go b/cmd/decolint/format_test.go index 90b9e3e..9bb50c5 100644 --- a/cmd/decolint/format_test.go +++ b/cmd/decolint/format_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/bare-devcontainer/decolint/cmd/decolint/format" + "github.com/bare-devcontainer/decolint/format" "github.com/bare-devcontainer/decolint/rules" "github.com/google/go-cmp/cmp" ) diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index 74da634..c6521db 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -16,11 +16,11 @@ import ( "strings" "syscall" - "github.com/bare-devcontainer/decolint/cmd/decolint/discovery" + "github.com/bare-devcontainer/decolint/discovery" "github.com/bare-devcontainer/decolint/feature" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" - "github.com/bare-devcontainer/decolint/cmd/decolint/substitute" + "github.com/bare-devcontainer/decolint/substitute" ) // progName is the program name, used in the flag set, usage text, and error messages. diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index 2411367..3178f1f 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -14,7 +14,7 @@ import ( "strings" "testing" - "github.com/bare-devcontainer/decolint/cmd/decolint/discovery" + "github.com/bare-devcontainer/decolint/discovery" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/ocitest" "github.com/bare-devcontainer/decolint/rules" diff --git a/cmd/decolint/discovery/discovery.go b/discovery/discovery.go similarity index 100% rename from cmd/decolint/discovery/discovery.go rename to discovery/discovery.go diff --git a/cmd/decolint/discovery/discovery_test.go b/discovery/discovery_test.go similarity index 100% rename from cmd/decolint/discovery/discovery_test.go rename to discovery/discovery_test.go diff --git a/cmd/decolint/discovery/testdata/feature/devcontainer-feature.json b/discovery/testdata/feature/devcontainer-feature.json similarity index 100% rename from cmd/decolint/discovery/testdata/feature/devcontainer-feature.json rename to discovery/testdata/feature/devcontainer-feature.json diff --git a/cmd/decolint/discovery/testdata/project/.devcontainer/devcontainer.json b/discovery/testdata/project/.devcontainer/devcontainer.json similarity index 100% rename from cmd/decolint/discovery/testdata/project/.devcontainer/devcontainer.json rename to discovery/testdata/project/.devcontainer/devcontainer.json diff --git a/cmd/decolint/discovery/testdata/project/.devcontainer/go/devcontainer.json b/discovery/testdata/project/.devcontainer/go/devcontainer.json similarity index 100% rename from cmd/decolint/discovery/testdata/project/.devcontainer/go/devcontainer.json rename to discovery/testdata/project/.devcontainer/go/devcontainer.json diff --git a/cmd/decolint/discovery/testdata/rootfile/.devcontainer.json b/discovery/testdata/rootfile/.devcontainer.json similarity index 100% rename from cmd/decolint/discovery/testdata/rootfile/.devcontainer.json rename to discovery/testdata/rootfile/.devcontainer.json diff --git a/cmd/decolint/discovery/testdata/template-no-devcontainer/devcontainer-template.json b/discovery/testdata/template-no-devcontainer/devcontainer-template.json similarity index 100% rename from cmd/decolint/discovery/testdata/template-no-devcontainer/devcontainer-template.json rename to discovery/testdata/template-no-devcontainer/devcontainer-template.json diff --git a/cmd/decolint/discovery/testdata/template-rootfile/.devcontainer.json b/discovery/testdata/template-rootfile/.devcontainer.json similarity index 100% rename from cmd/decolint/discovery/testdata/template-rootfile/.devcontainer.json rename to discovery/testdata/template-rootfile/.devcontainer.json diff --git a/cmd/decolint/discovery/testdata/template-rootfile/devcontainer-template.json b/discovery/testdata/template-rootfile/devcontainer-template.json similarity index 100% rename from cmd/decolint/discovery/testdata/template-rootfile/devcontainer-template.json rename to discovery/testdata/template-rootfile/devcontainer-template.json diff --git a/cmd/decolint/discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json b/discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json similarity index 100% rename from cmd/decolint/discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json rename to discovery/testdata/template-subfolder/.devcontainer/go/devcontainer.json diff --git a/cmd/decolint/discovery/testdata/template-subfolder/devcontainer-template.json b/discovery/testdata/template-subfolder/devcontainer-template.json similarity index 100% rename from cmd/decolint/discovery/testdata/template-subfolder/devcontainer-template.json rename to discovery/testdata/template-subfolder/devcontainer-template.json diff --git a/cmd/decolint/discovery/testdata/template/.devcontainer/devcontainer.json b/discovery/testdata/template/.devcontainer/devcontainer.json similarity index 100% rename from cmd/decolint/discovery/testdata/template/.devcontainer/devcontainer.json rename to discovery/testdata/template/.devcontainer/devcontainer.json diff --git a/cmd/decolint/discovery/testdata/template/devcontainer-template.json b/discovery/testdata/template/devcontainer-template.json similarity index 100% rename from cmd/decolint/discovery/testdata/template/devcontainer-template.json rename to discovery/testdata/template/devcontainer-template.json diff --git a/cmd/decolint/format/doc.go b/format/doc.go similarity index 100% rename from cmd/decolint/format/doc.go rename to format/doc.go diff --git a/cmd/decolint/format/github.go b/format/github.go similarity index 100% rename from cmd/decolint/format/github.go rename to format/github.go diff --git a/cmd/decolint/format/github_test.go b/format/github_test.go similarity index 100% rename from cmd/decolint/format/github_test.go rename to format/github_test.go diff --git a/cmd/decolint/format/json.go b/format/json.go similarity index 100% rename from cmd/decolint/format/json.go rename to format/json.go diff --git a/cmd/decolint/format/json_test.go b/format/json_test.go similarity index 100% rename from cmd/decolint/format/json_test.go rename to format/json_test.go diff --git a/cmd/decolint/format/sarif.go b/format/sarif.go similarity index 100% rename from cmd/decolint/format/sarif.go rename to format/sarif.go diff --git a/cmd/decolint/format/sarif_test.go b/format/sarif_test.go similarity index 100% rename from cmd/decolint/format/sarif_test.go rename to format/sarif_test.go diff --git a/cmd/decolint/format/text.go b/format/text.go similarity index 100% rename from cmd/decolint/format/text.go rename to format/text.go diff --git a/cmd/decolint/format/text_test.go b/format/text_test.go similarity index 100% rename from cmd/decolint/format/text_test.go rename to format/text_test.go diff --git a/cmd/decolint/substitute/substitute.go b/substitute/substitute.go similarity index 100% rename from cmd/decolint/substitute/substitute.go rename to substitute/substitute.go diff --git a/cmd/decolint/substitute/substitute_test.go b/substitute/substitute_test.go similarity index 100% rename from cmd/decolint/substitute/substitute_test.go rename to substitute/substitute_test.go From ab3a43c09299624891dd2a02e75d13a1f6677070 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 00:54:30 +0000 Subject: [PATCH 06/24] feat(cli): drop -explain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the rule reference published as a site, the flag printed a rule's one-line description and the address of its page — everything it had left to say once the rationale moved out of the binary. The SARIF output carries that address for the rules it reports, and the README's rules table links each ID to its page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- CONTRIBUTING.md | 5 ++-- README.md | 8 ++---- cmd/decolint/main.go | 32 ----------------------- cmd/decolint/main_test.go | 55 --------------------------------------- cmd/decolint/opts.go | 4 --- cmd/decolint/opts_test.go | 27 ------------------- 6 files changed, 4 insertions(+), 127 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index caf2b71..40099ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,9 +85,8 @@ rule lands, also add a row for it to the table in Every rule has a page under [`docs/content/rules/`](docs/content/rules/), named after its ID, which is where the reasoning and the examples live. -It is what `decolint -explain`, the SARIF output, and every code -scanning alert link to, so write it for the user who just hit the -finding. +It is what the SARIF output, and so every code scanning alert, links +to, so write it for the user who just hit the finding. ````markdown --- diff --git a/README.md b/README.md index 8777818..8ad987b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,6 @@ The remaining flags perform a one-off action and exit; run | `-config ` | use the config file at `` instead of [auto-discovery](#config-file) | | `-init` | write a new `.decolint.jsonc` listing every rule at its default severity | | `-rules` | print the built-in rules as a Markdown table | -| `-explain ` | print what one rule checks and where it is documented | | `-version` | print version information | | `-help` | print usage | @@ -286,11 +285,8 @@ to all platforms. Every rule has a page on the [documentation site](https://bare-devcontainer.github.io/decolint/rules/) covering why it exists, the configuration it accepts and rejects, and the -specification it is based on. To look one up from the command line: - -```console -decolint -explain no-privileged-container -``` +specification it is based on. Each rule ID in the table below links to +its page. The [SARIF output](#output-formats) links every rule it reports to the same page, so the reasoning is one click away from the alert in GitHub diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index c6521db..41144c2 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -12,7 +12,6 @@ import ( "os/signal" "path" "path/filepath" - "slices" "strings" "syscall" @@ -80,14 +79,6 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return exitCodeSuccess } - if opts.Explain != "" { - if err := explainRule(stdout, opts.Explain); err != nil { - _, _ = fmt.Fprintln(stderr, progName+":", err) - return exitCodeError - } - return exitCodeSuccess - } - cfg, err := loadConfig(opts.ConfigPath) if err != nil { _, _ = fmt.Fprintln(stderr, progName+":", err) @@ -177,29 +168,6 @@ func platformNames(platforms []linter.Platform, sep string) string { return strings.Join(names, sep) } -// explainRule writes what decolint knows about the rule with the given ID to output: what it -// checks, the platforms it applies to, and where it is documented in full. It returns an error if -// no built-in rule has that ID. The rule's severity is left to [listRules], which reports it for -// every rule at once. -func explainRule(output io.Writer, id string) error { - builtin := rules.Builtin() - i := slices.IndexFunc(builtin, func(reg rules.Registration) bool { return reg.Rule.ID == id }) - if i < 0 { - return fmt.Errorf("unknown rule ID %q; run with -rules to list the built-in rules", id) - } - rule := builtin[i].Rule - - sections := []string{ - fmt.Sprintf("%s (%s)\nPlatform: %s", rule.ID, rule.Category, platformNames(rule.Platforms, ", ")), - rule.Description, - "Documentation: " + rules.DocsURL(rule.ID), - } - if _, err := fmt.Fprintln(output, strings.Join(sections, "\n\n")); err != nil { - return fmt.Errorf("write rule documentation: %w", err) - } - return nil -} - // columnWidths returns, for each column in rows, the display width of its widest cell, so every // row can be padded to a common set of column widths. func columnWidths(rows [][]string) []int { diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index 3178f1f..7b74891 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -434,49 +434,6 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-explain", func(t *testing.T) { - t.Parallel() - - var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-explain=no-bind-mount"}, &stdout, &stderr) - if exitCode != 0 { - t.Errorf("exit code = %d, want 0", exitCode) - } - if stderr.String() != "" { - t.Errorf("stderr = %q, want empty", stderr.String()) - } - out := stdout.String() - rule := builtinRule(t, "no-bind-mount") - for _, want := range []string{ - rule.ID, - rule.Category.String(), - // The rule targets Codespaces, so the platform it is scoped to is named rather than "(all)". - "codespaces", - rule.Description, - rules.DocsURL(rule.ID), - } { - if !strings.Contains(out, want) { - t.Errorf("stdout = %q, want it to contain %q", out, want) - } - } - }) - - t.Run("-explain unknown rule", func(t *testing.T) { - t.Parallel() - - var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-explain=no-such-rule"}, &stdout, &stderr) - if exitCode != 2 { - t.Errorf("exit code = %d, want 2", exitCode) - } - if stdout.String() != "" { - t.Errorf("stdout = %q, want empty", stdout.String()) - } - if !strings.Contains(stderr.String(), "no-such-rule") { - t.Errorf("stderr = %q, want it to mention the unknown rule ID", stderr.String()) - } - }) - t.Run("-help", func(t *testing.T) { t.Parallel() @@ -1874,18 +1831,6 @@ func mdTableRow(t *testing.T, out, key string) []string { return nil } -// builtinRule returns the built-in rule with the given ID, failing the test if there is none. -func builtinRule(t *testing.T, id string) *linter.Rule { - t.Helper() - for _, reg := range rules.Builtin() { - if reg.Rule.ID == id { - return reg.Rule - } - } - t.Fatalf("no built-in rule with ID %q", id) - return nil -} - // writeDevcontainer writes a minimal devcontainer.json, with the given body as its content, at the // spec-defined location under a fresh temp directory, and returns that directory's path. func writeDevcontainer(t *testing.T, body string) string { diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index 9ea8263..4e71840 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -44,9 +44,6 @@ type Options struct { Version bool // ListRules, when set, causes the program to print the built-in rules and exit. ListRules bool - // Explain, when non-empty, is the ID of the rule to describe in full; the program prints its - // documentation and exits. - Explain string // Init, when set, causes the program to write a new .decolint.jsonc config file listing every // rule at its default severity, then exit. Init bool @@ -67,7 +64,6 @@ func parseOptions(args []string, output io.Writer) (Options, error) { fs.BoolVar(&opts.Merge, "merge", false, "lint the merged (effective) configuration, including referenced Features and base image metadata; overrides the config file's \"merge\" member") fs.BoolVar(&opts.Version, "version", false, "print version information and exit") fs.BoolVar(&opts.ListRules, "rules", false, "print the built-in rules as a Markdown table (category, target platforms, current severity), then exit") - fs.StringVar(&opts.Explain, "explain", "", "print what the rule with this ID checks and where it is documented, then exit") fs.BoolVar(&opts.Init, "init", false, "write a new .decolint.jsonc config file listing every rule at its default severity, then exit") fs.Usage = func() { _ = usage(fs) } if err := fs.Parse(args); err != nil { diff --git a/cmd/decolint/opts_test.go b/cmd/decolint/opts_test.go index e20593f..d9c48d8 100644 --- a/cmd/decolint/opts_test.go +++ b/cmd/decolint/opts_test.go @@ -196,33 +196,6 @@ func TestParseOptions_DenyWarningsSet(t *testing.T) { } } -func TestParseOptions_Explain(t *testing.T) { - t.Parallel() - - // The rule ID is captured verbatim; explainRule is what rejects one that names no built-in rule. - tests := []struct { - name string - args []string - want string - }{ - {"no flag", nil, ""}, - {"rule id", []string{"-explain=no-image-latest"}, "no-image-latest"}, - {"unknown rule id", []string{"-explain=bogus"}, "bogus"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - opts, err := parseOptions(tt.args, io.Discard) - if err != nil { - t.Fatalf("parseOptions(%v) error = %v", tt.args, err) - } - if opts.Explain != tt.want { - t.Errorf("Explain = %q, want %q", opts.Explain, tt.want) - } - }) - } -} - func TestParseOptions_Config(t *testing.T) { t.Parallel() From 5052c81d6467959998f49f185594ba8dc8db6d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 01:10:10 +0000 Subject: [PATCH 07/24] fix: make site-serve usable from the workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hugo server keeps the path of the configured baseURL, so it served the site under /decolint/ and left http://localhost:1313/ a 404 — the top page only appeared at the address hugo prints, and every link typed by hand missed. Override the base URL for local preview, with the port a variable so the two cannot disagree. Two things around it: - The build lock is ignored wherever it lands. Hugo writes it in the directory it runs from, which is not always docs/, and the anchored pattern only covered that one. - site, site-serve and site-syntax check the hugo on PATH first. The layouts use the flat layouts/ lookup from Hugo 0.146, and an older hugo renders a page it finds no layout for as an empty one instead of failing, which reaches the browser as a blank page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- .gitignore | 3 ++- Makefile | 31 ++++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index c93172f..fb93f34 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,8 @@ profile.cov /bin/ /docs/public/ /docs/resources/ -/docs/.hugo_build.lock +# Hugo writes its build lock in the directory it runs from, which is not always docs/. +.hugo_build.lock # Go workspace file go.work diff --git a/Makefile b/Makefile index 54ce30d..ec83102 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ HUGO_VERSION := v0.164.0 # release. Its version is pinned in the Pages workflow, which is what publishes the site. HUGO ?= hugo +# Port for site-serve. +SITE_PORT ?= 1313 + VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) REVISION := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GOBUILDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X main.revision=$(REVISION)" @@ -45,15 +48,17 @@ lint: ## Run all lint rules go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run .PHONY: site -site: ## Build the documentation site into docs/public +site: check-hugo ## Build the documentation site into docs/public $(HUGO) --source docs --minify .PHONY: site-serve -site-serve: ## Serve the documentation site with live reload - $(HUGO) server --source docs +site-serve: check-hugo ## Serve the documentation site with live reload + # hugo server keeps the path of the configured baseURL, which would serve the site under + # /decolint/ and leave http://localhost:$(SITE_PORT)/ a 404. Override it for local preview. + $(HUGO) server --source docs --port $(SITE_PORT) --baseURL http://localhost:$(SITE_PORT)/ .PHONY: site-syntax -site-syntax: ## Regenerate the syntax highlighting stylesheet +site-syntax: check-hugo ## Regenerate the syntax highlighting stylesheet @{ \ echo '/* Chroma syntax highlighting token colours.'; \ echo ' Generated by `make site-syntax`; edit that target rather than this file. */'; \ @@ -64,9 +69,25 @@ site-syntax: ## Regenerate the syntax highlighting stylesheet echo '}'; \ } > docs/assets/css/syntax.css +# The site's templates use the flat layouts/ lookup Hugo introduced in 0.146. An older hugo does +# not fail on them: it renders the pages it cannot find a layout for as empty ones, so check the +# version rather than let that reach the browser. +.PHONY: check-hugo +check-hugo: + @have=$$($(HUGO) version 2>/dev/null | sed -n 's/^hugo v\([0-9][0-9.]*\).*/\1/p'); \ + want=$(HUGO_VERSION:v%=%); \ + if [ -z "$$have" ]; then \ + echo "$(HUGO): not found; install Hugo $$want or newer, or set HUGO=/path/to/hugo" >&2; \ + exit 1; \ + fi; \ + if [ "$$(printf '%s\n%s\n' "$$want" "$$have" | sort -V | head -n 1)" != "$$want" ]; then \ + echo "hugo $$have is older than $$want, which the site's layouts need" >&2; \ + exit 1; \ + fi + .PHONY: clean clean: ## Remove build artifacts - rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.hugo_build.lock + rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.hugo_build.lock .hugo_build.lock .PHONY: install install: ## Install the decolint binary to GOPATH/bin From 2805ec55fac17d1a1e740bf5854d6784f77b8413 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:27:17 +0000 Subject: [PATCH 08/24] docs: bring the site's prose in line with the rewritten README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page and getting-started page were written against the old README and had drifted: the sample output predates the run header, the setup guide was a short list where the README now walks through five steps, and neither mentioned linting a published Feature or Template. Mirror the README's structure — try it, install, the five setup steps, CI, Features and Templates — with the outputs captured from a real run, and point at the README's reference section for the exhaustive flag and config tables rather than repeating them. Also settles on "color" over "colour" across the site and the Makefile, which is the spelling the README and the CLI use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- Makefile | 2 +- docs/assets/css/main.css | 4 +- docs/assets/css/syntax.css | 2 +- docs/content/_index.md | 86 +++++++--- docs/content/getting-started.md | 280 +++++++++++++++++++++++--------- docs/content/rules/_index.md | 6 +- docs/hugo.toml | 2 +- 7 files changed, 271 insertions(+), 111 deletions(-) diff --git a/Makefile b/Makefile index ec83102..c7ec209 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ site-serve: check-hugo ## Serve the documentation site with live reload .PHONY: site-syntax site-syntax: check-hugo ## Regenerate the syntax highlighting stylesheet @{ \ - echo '/* Chroma syntax highlighting token colours.'; \ + echo '/* Chroma syntax highlighting token colors.'; \ echo ' Generated by `make site-syntax`; edit that target rather than this file. */'; \ echo; \ $(HUGO) gen chromastyles --style=github | tail -n +2; \ diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index ac48ab4..3b7f8b9 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -1,6 +1,6 @@ /* decolint documentation site. - Colours are declared once as custom properties and re-declared for a dark preference; the - Chroma token colours generated by `make site-syntax` live in syntax.css. */ + Colors are declared once as custom properties and re-declared for a dark preference; the + Chroma token colors generated by `make site-syntax` live in syntax.css. */ :root { color-scheme: light dark; diff --git a/docs/assets/css/syntax.css b/docs/assets/css/syntax.css index 435d2f9..949131a 100644 --- a/docs/assets/css/syntax.css +++ b/docs/assets/css/syntax.css @@ -1,4 +1,4 @@ -/* Chroma syntax highlighting token colours. +/* Chroma syntax highlighting token colors. Generated by `make site-syntax`; edit that target rather than this file. */ diff --git a/docs/content/_index.md b/docs/content/_index.md index d8d81fc..7d65adc 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -2,37 +2,75 @@ title: decolint --- -decolint is a linter for Dev Container configuration files. It reads -`devcontainer.json`, `devcontainer-feature.json` and -`devcontainer-template.json`, checks them against the -[Dev Container specification](https://containers.dev/), and reports what is -invalid, unsafe, or unreproducible — before anyone waits for a container to -build. +decolint is a linter for [Dev Container](https://containers.dev/) configuration +files: `devcontainer.json`, `devcontainer-feature.json`, and +`devcontainer-template.json`. It reports mistakes, container privileges, and +unpinned versions that the Dev Container tooling itself accepts without a word. + +Take a `devcontainer.json` that opens cleanly in VS Code and builds without +complaint: + +```jsonc +// .devcontainer/devcontainer.json +{ + "name": "api", + "image": "mcr.microsoft.com/devcontainers/go:latest", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker": {} + }, + "runArgs": ["--privileged"], + "mounts": [ + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + ], + "forwardPorts": ["db:5432"] +} +``` ```console $ decolint . -.devcontainer/devcontainer.json:4:3: error: devcontainer.json must define only one of "image", "build", or "dockerComposeFile" (conflicting-container-def) -Found 1 error and 0 warnings. +Config: .decolint.jsonc +Linted 1 file: + .devcontainer/devcontainer.json (devcontainer) + +.devcontainer/devcontainer.json:1:1: error: "ALL" is not set via "runArgs", leaving the container with its default Linux capabilities (require-cap-drop-all) +.devcontainer/devcontainer.json:1:1: error: "no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges (require-no-new-privileges) +.devcontainer/devcontainer.json:1:1: error: neither "remoteUser" nor "containerUser" is set, so the container defaults to running as root (require-non-root) +.devcontainer/devcontainer.json:3:12: error: image "mcr.microsoft.com/devcontainers/go:latest" uses the "latest" tag; pin a specific version (no-image-latest) +.devcontainer/devcontainer.json:3:12: error: image "mcr.microsoft.com/devcontainers/go:latest" is not pinned by digest; add an "@sha256:..." digest (pin-image-digest) +.devcontainer/devcontainer.json:5:5: error: feature "ghcr.io/devcontainers/features/docker-in-docker" has no explicit version; pin a specific version (pin-feature-version) +.devcontainer/devcontainer.json:7:15: error: "runArgs" contains "--privileged", disabling the container's isolation from the host (no-privileged-container) +.devcontainer/devcontainer.json:9:5: error: "mounts" entry bind-mounts the Docker socket, which grants the container root-equivalent control over the host (no-docker-socket-mount) +.devcontainer/devcontainer.json:11:20: error: "forwardPorts" entry "db:5432" uses "host:port" format; Codespaces only supports a bare port number (no-host-port-format) +Found 9 errors and 0 warnings. ``` +That run has every category and both target platforms enabled; the defaults are +quieter. See [Getting started](getting-started.md). + ## Why decolint -- **It reads the same files the tooling does.** Comments and trailing commas - are parsed as the specification requires, and findings point at the exact - line and column of the offending value. -- **It runs without Docker.** No image is pulled and no container is built, so - it fits in a pre-commit hook or the first job of a pipeline. Rules that - resolve Features against a registry are the only ones that reach the network. -- **It reports what your project cares about.** Rules are grouped into - [categories](getting-started.md#choosing-what-to-report) — correctness, - security, reproducibility and style — and only correctness is on by default. -- **It fits into code scanning.** The SARIF output uploads to GitHub Code - Scanning, so findings land as annotations on the pull request that - introduced them. +- **A `devcontainer.json` is container runtime configuration, and nobody reviews + it that way.** `--privileged` and a bind-mounted Docker socket read like + ordinary setup lines, and they ship to every teammate and every CI run that + rebuilds the container. +- **It lints what actually runs, not just what you wrote.** The Features you + reference and the base image you name contribute configuration of their own. + With [`-merge`](getting-started.md#4-lint-what-actually-runs), decolint + resolves them the way the real tooling does and lints the result. +- **A silently ignored property is a mistake decolint names.** GitHub Codespaces + drops `bind` mounts and rejects `host:port` entries without reporting + anything; the container just comes up wrong. +- **It goes where your other linters already are.** Findings carry a line and + column, and come out as text, JSON, GitHub Actions annotations, or SARIF. +- **Every finding is explained.** Each rule has a [page here](rules/) covering + why it exists and the configuration it accepts and rejects, and the SARIF + output links to it, so a code scanning alert carries the reasoning with it. ## Next steps -- [Getting started](getting-started.md) — install decolint, run it, and wire it - into CI. -- [Rules](rules/) — every rule, with the reasoning behind it and examples of - the configuration it accepts and rejects. +- [Getting started](getting-started.md) — install decolint, choose what it + reports, and wire it into CI. +- [Rules](rules/) — every rule, with the reasoning behind it and examples of the + configuration it accepts and rejects. +- [README](https://github.com/bare-devcontainer/decolint#reference) — the full + reference for flags, the config file, output formats and merging. diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index 6cb104e..e4317e9 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -1,10 +1,24 @@ --- title: Getting started +description: >- + Install decolint, choose what it reports, and wire it into CI. --- -This page takes you from an empty shell to decolint running in CI. Every -setting it mentions is documented in full in the -[README](https://github.com/bare-devcontainer/decolint#readme). +This page takes you from nothing installed to decolint running in CI. Every +flag and config member it mentions is documented in full in the +[README's reference](https://github.com/bare-devcontainer/decolint#reference). + +## Try it + +Run it against your own repository, without installing anything: + +```console +docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint +``` + +By default only the `correctness` checks run — configuration that is invalid or +does not behave as written. The security, pinning, and style checks are opt-in, +and one config file away. ## Install @@ -12,13 +26,14 @@ A prebuilt binary is the quickest way to start. Download one from the [releases page](https://github.com/bare-devcontainer/decolint/releases); the artifacts are signed and carry build provenance. -To run it as a container instead: +The container image is published for `linux/amd64` and `linux/arm64`, tagged +`latest`, ``, `.`, and `..`: ```console -docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint +docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint [directory ...] ``` -Or build it from source, which needs Go 1.26 or newer: +Or build it from source: ```console GOEXPERIMENT=jsonv2 go install github.com/bare-devcontainer/decolint/cmd/decolint@latest @@ -27,32 +42,65 @@ GOEXPERIMENT=jsonv2 go install github.com/bare-devcontainer/decolint/cmd/decolin `GOEXPERIMENT=jsonv2` is required because decolint uses the still experimental `encoding/json/v2` package. -## Run it +## 1. Run it -Point decolint at a directory, or at nothing to lint the current one: +With no arguments the current directory is linted; name directories to lint +those instead. Whatever you point it at is detected as a dev container +definition, a Feature, or a Template. ```console $ decolint -.devcontainer/devcontainer.json:3:12: error: "build" is missing "dockerfile" (missing-build-dockerfile) -Found 1 error and 0 warnings. +Config: none (defaults; run "decolint -init" to create .decolint.jsonc) +Linted 1 file: + .devcontainer/devcontainer.json (devcontainer) + +Found 0 errors and 0 warnings. ``` -decolint works out what each directory is from its layout and lints the -configuration files it finds: +The header says why a run reported nothing: no config file, so only the +defaults are in effect. It also lists what was covered, which is the other +thing worth checking. -- a **dev container definition** — `.devcontainer/devcontainer.json`, - `.devcontainer.json`, or `.devcontainer//devcontainer.json` -- a **Feature** — `devcontainer-feature.json` -- a **Template** — `devcontainer-template.json`, plus the dev container - configuration the template ships +## 2. Turn on the checks you want -It exits `0` when nothing was reported at `error` severity, `1` when something -was, and `2` if it could not do its job — a file that does not parse, say. +The defaults are `correctness` alone. Everything else — the container +privileges, the unpinned versions, the legacy properties — is off until you ask +for it. Write a `.decolint.jsonc` in your repository root: -## Choosing what to report +```jsonc +// .decolint.jsonc +{ + "categories": { + "correctness": "error", + "security": "error", + "reproducibility": "error", + "style": "error" + } +} +``` -Every rule belongs to one of four categories, and only `correctness` is -enabled out of the box: +That is the strictest setting. Start narrower if a whole category is more than +you want today, and set individual rules where a category is close but not +right: + +```jsonc +{ + "categories": { + "security": "error" + }, + "rules": { + "no-image-latest": "error", + "require-non-root": "off" + } +} +``` + +Every severity is `error`, `warn`, or `off`, and a `rules` entry beats its +category. Run `decolint -rules` to see every rule with the severity your config +gives it, or `decolint -init` to generate a config that lists all of them +explicitly. + +Each category groups rules by the kind of problem they report: | Category | Default | Reports | | --- | --- | --- | @@ -61,96 +109,170 @@ enabled out of the box: | `reproducibility` | `off` | unpinned versions or digests that let the environment drift | | `style` | `off` | discouraged or legacy configuration that still works | -Turn the others on in a config file. `decolint -init` writes one listing every -rule, ready to edit: +The [rule reference](rules/) lists what each one checks and why. + +## 3. Name your platform + +Some rules only make sense on a particular platform — Codespaces ignoring +`bind` mounts, VS Code pinning extension versions — and those stay off until +you say which platforms you target: ```jsonc -// .decolint.jsonc { - "categories": { - "security": "error", - "reproducibility": "warn" - }, - "rules": { - "pin-image-digest": "off" - } + "platforms": ["vscode", "codespaces"] } ``` -`rules` overrides individual rules and wins over their category. Both accept -`error`, `warn` and `off`. The [rule reference](rules/) lists what each rule -checks and why. +## 4. Lint what actually runs + +Your `devcontainer.json` is not the whole configuration. Features and the base +image contribute their own, and the tooling merges it all together before the +container starts. Take a file that is careful about all of it — pinned by +digest, capabilities dropped, no new privileges: + +```jsonc +// .devcontainer/devcontainer.json +{ + "name": "api", + "image": "mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3", + "runArgs": ["--cap-drop=ALL"], + "securityOpt": ["no-new-privileges"] +} +``` -Some rules apply only to a particular platform — Codespaces ignores `bind` -mounts, for instance, which is worth reporting only if you use Codespaces. -Those rules stay off until you name the platform: +Linted as written it reports one problem. Merging replaces it with four: ```console -decolint -platform=vscode,codespaces +$ decolint . +Config: .decolint.jsonc +Linted 1 file: + .devcontainer/devcontainer.json (devcontainer) + +.devcontainer/devcontainer.json:1:1: error: neither "remoteUser" nor "containerUser" is set, so the container defaults to running as root (require-non-root) +Found 1 error and 0 warnings. + +$ decolint -merge . +Downloading image metadata(mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3) +Config: .decolint.jsonc +Linted 1 file: + .devcontainer/devcontainer.json (devcontainer) + +.devcontainer/devcontainer.json:3:3: error: "securityOpt" overrides the default seccomp profile (no-seccomp-override) +.devcontainer/devcontainer.json:3:3: error: "securityOpt" contains "seccomp=unconfined", disabling the container's syscall filtering (no-seccomp-unconfined) +.devcontainer/devcontainer.json:3:3: error: extension "golang.Go" has no explicit version; pin a specific version (pin-extension-version) +.devcontainer/devcontainer.json:3:3: error: extension "dbaeumer.vscode-eslint" has no explicit version; pin a specific version (pin-extension-version) +Found 4 errors and 0 warnings. ``` -## Lint what actually runs +The one reported without merging was wrong: the base image sets a non-root +user, so `require-non-root` never applied. The four reported with merging are +in nothing you wrote — the image disables seccomp and installs two unpinned VS +Code extensions. Findings that come from merged content are reported at the +property that pulled it in, here the `image` line. -A container's configuration is not only what its own file says: the base image -and every Feature it references contribute their own, and the tooling merges -them all. A Feature that sets `privileged: true` is invisible to a linter that -reads only `devcontainer.json`. +Turn it on for good with `"merge": true`, or pass `-merge` per run. It fetches +every referenced Feature and resolves the base image, so it needs network +access and belongs in CI rather than in a pre-commit hook. -`-merge` resolves the base image and every referenced Feature, applies the -specification's merge logic, and lints the result: +## 5. Fix it, or say why not -```console -decolint -merge +When a finding is deliberate, suppress it in the configuration file itself so +the reason lives next to the line: + +```jsonc +{ + // decolint-ignore-next-line no-image-latest + "image": "ubuntu:latest" +} ``` -This one reaches the network, so it belongs in CI rather than in a pre-commit -hook. A Feature or image that cannot be fetched is an error. +`decolint-ignore-line` covers the line it is on, `decolint-ignore-next-line` +the line after it, and `decolint-ignore-file` the whole file. Naming rule IDs +after the directive limits it to those rules; naming none suppresses +everything. -## In CI +## Add it to CI -On GitHub Actions, the `github` format turns findings into inline annotations -on the pull request diff: +decolint exits `1` when it reports an `error`, which is all a CI job needs to +fail. Add `-format=github` and the findings also appear as annotations on the +pull request diff: ```yaml -- run: decolint -format=github -deny-warnings . +name: devcontainer +on: pull_request +permissions: {} + +jobs: + decolint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=github . ``` -`-deny-warnings` makes `warn` findings fail the job too; without it only -`error` findings do. +Only findings become annotations; the files that were linted go to a collapsed +group in the run log, so a clean file does not annotate the diff. Warnings do +not fail the build on their own; add `-deny-warnings` to make them count. -To collect findings as alerts in the repository's Security tab instead, write -a SARIF log and upload it: +To have findings tracked as alerts in the repository's Security tab instead, +emit SARIF and upload it: ```yaml -- run: decolint -merge -format=sarif . > decolint.sarif - continue-on-error: true # decolint exits 1 on findings; the upload must still run -- uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: decolint.sarif + decolint-sarif: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=sarif . > decolint.sarif + continue-on-error: true # decolint exits 1 on findings; the upload must still run + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: decolint.sarif ``` -Each alert links back to the rule's page here, so whoever picks it up gets the -reasoning without leaving the alert. +A pull request from a fork gets a read-only token whatever the job asks for, so +the upload cannot succeed there; skip the job on fork pull requests, or run it +only on pushes to your default branch. -## Silencing a finding +Run decolint from the repository root, so the reported paths resolve to files +in the repository. Each alert links back to the rule's page here, so whoever +picks it up gets the reasoning without leaving the alert. -When a rule is right in general but wrong for one line, suppress it in the -configuration file itself rather than turning the rule off everywhere: +## Linting a Feature or Template -```jsonc -{ - // decolint-ignore-line no-image-latest - "image": "mcr.microsoft.com/devcontainers/base:latest" -} +If you publish a [Feature](https://containers.dev/implementors/features/) or a +[Template](https://containers.dev/implementors/templates/), the mistakes that +cost the most are the ones your consumers find after you have shipped: an `id` +that does not match its directory, a version that is not semver, an `install.sh` +that was committed without its executable bit. Point decolint at the directory — +these are `correctness` rules, so they need no configuration: + +```console +$ decolint src/go-tools +Config: none (defaults; run "decolint -init" to create .decolint.jsonc) +Linted 1 file: + src/go-tools/devcontainer-feature.json (feature) + +src/go-tools/devcontainer-feature.json:1:1: error: "install.sh" is not executable (mode 0644); run "chmod +x install.sh" (feature-install-script-not-executable) +src/go-tools/devcontainer-feature.json:2:9: error: id "gotools" does not match containing directory "go-tools" (id-dir-mismatch) +src/go-tools/devcontainer-feature.json:3:14: error: version "1.0" is not a valid semantic version (see https://semver.org/) (invalid-semver) +Found 3 errors and 0 warnings. ``` -`decolint-ignore-line` covers the line it is on, `decolint-ignore-next-line` -the line after it, and `decolint-ignore-file` the whole file. Naming rule IDs -after the directive limits it to those rules; naming none suppresses -everything on that line. +A Template directory is linted the same way, and the dev container +configuration the Template ships is linted along with it, including its +`${templateOption:...}` references. ## Next steps - [Rules](rules/) — what each rule checks, why, and what it accepts. -- [README](https://github.com/bare-devcontainer/decolint#readme) — the full - reference for flags, the config file, output formats and merging. +- [README](https://github.com/bare-devcontainer/decolint#reference) — the full + reference for flags, the config file, output formats, color and merging. diff --git a/docs/content/rules/_index.md b/docs/content/rules/_index.md index 7405d9a..8c127d9 100644 --- a/docs/content/rules/_index.md +++ b/docs/content/rules/_index.md @@ -5,6 +5,6 @@ description: >- --- Each rule belongs to one category, which sets its severity unless a -[config file](../getting-started.md#choosing-what-to-report) overrides it; only -`correctness` is enabled out of the box. A rule that names a platform runs only -when that platform is selected with `-platform`. +[config file](../getting-started.md#2-turn-on-the-checks-you-want) overrides +it; only `correctness` is enabled out of the box. A rule that names a platform +runs only when that platform is selected with `-platform`. diff --git a/docs/hugo.toml b/docs/hugo.toml index 4462ac2..881dff1 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -17,7 +17,7 @@ disableKinds = ["taxonomy", "term", "RSS"] repository = "https://github.com/bare-devcontainer/decolint" [markup.highlight] - # Emit token classes rather than inline styles, so the colours live in the site's own stylesheet + # Emit token classes rather than inline styles, so the colors live in the site's own stylesheet # and follow the reader's light or dark preference. noClasses = false From d404f402052ff9f76fd361efb6dc25c78bae46ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:35:54 +0000 Subject: [PATCH 09/24] build: make hugo a go tool dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages workflow downloaded a release tarball and checked its sha256, and a local build needed hugo installed separately at a version the Makefile named but could not enforce — an older one on PATH rendered pages it found no layout for as blank ones. A tool directive pins the version where every other dependency is pinned, and "make site" builds it from the module cache with nothing installed. The version guard and the workflow's download step both go away with it, and the Pages job now needs only actions/setup-go. Hugo brings a large graph with it: 64 require lines become 216 and go.sum grows from 159 lines to 971. None of it reaches the product: the decolint binary is byte-for-byte the size it was, and "go version -m" lists no hugo module in it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- .github/workflows/pages.yml | 21 +- CONTRIBUTING.md | 9 +- Makefile | 31 +- go.mod | 158 ++++++- go.sum | 836 +++++++++++++++++++++++++++++++++++- 5 files changed, 996 insertions(+), 59 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e015891..eb7872c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -24,12 +24,6 @@ concurrency: group: pages cancel-in-progress: false -env: - # renovate: datasource=github-releases depName=gohugoio/hugo - HUGO_VERSION: 0.164.0 - # sha256 of hugo_${HUGO_VERSION}_linux-amd64.tar.gz, from the release's checksums.txt. - HUGO_SHA256: d9c8b17285ea4ec004d9f814273ea910f2051ce02c284993fd1f91ba455ae50d - jobs: build: runs-on: ubuntu-latest @@ -40,16 +34,11 @@ jobs: with: persist-credentials: false - # Installed from the release archive, pinned by checksum, rather than through a third-party - # action: one fewer action to trust, and the version is the one the Makefile names. - - name: Install Hugo - run: | - url="https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz" - curl --fail --silent --show-error --location --output hugo.tar.gz "$url" - echo "${HUGO_SHA256} hugo.tar.gz" | sha256sum --check --strict - mkdir -p "$RUNNER_TEMP/hugo" - tar -xzf hugo.tar.gz -C "$RUNNER_TEMP/hugo" hugo - echo "$RUNNER_TEMP/hugo" >> "$GITHUB_PATH" + # Hugo is a tool dependency in go.mod, so the Go toolchain is all this needs: "make site" + # builds it at the pinned version and caches it like any other dependency. + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod - run: make site diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40099ea..135cd3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,11 +15,13 @@ make run ARGS="-format=json path/to/dir" ``` The documentation site in [`docs/`](docs/) is built with -[Hugo](https://gohugo.io/), pinned to the version the Makefile names: +[Hugo](https://gohugo.io/). It is a tool dependency in +[`go.mod`](go.mod), so there is nothing to install and the version is +pinned with the rest of them: ```console make site # build into docs/public -make site-serve # serve with live reload +make site-serve # serve with live reload at http://localhost:1313/ ``` ## Adding a rule @@ -140,8 +142,7 @@ Two things to know when writing the examples: The rule index and the sidebar are built from the pages themselves, so there is no list to update by hand. Preview the site with `make -site-serve`, which needs [Hugo](https://gohugo.io/) at the version the -[Makefile](Makefile) names. +site-serve`. When implementing or reviewing rules, consult the Dev Container specification at [containers.dev](https://containers.dev/) to confirm diff --git a/Makefile b/Makefile index c7ec209..2784211 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,9 @@ export GOEXPERIMENT := jsonv2 # renovate: datasource=github-releases depName=golangci/golangci-lint GOLANGCI_LINT_VERSION := v2.12.2 -# renovate: datasource=github-releases depName=gohugoio/hugo -HUGO_VERSION := v0.164.0 - -# The site is built with whichever hugo is on PATH; HUGO overrides it, e.g. to a downloaded -# release. Its version is pinned in the Pages workflow, which is what publishes the site. -HUGO ?= hugo +# Hugo is a tool dependency (see the tool directive in go.mod), so the version the site is built +# with is pinned there and needs nothing installed. HUGO overrides it with another binary. +HUGO ?= go tool hugo # Port for site-serve. SITE_PORT ?= 1313 @@ -48,17 +45,17 @@ lint: ## Run all lint rules go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run .PHONY: site -site: check-hugo ## Build the documentation site into docs/public +site: ## Build the documentation site into docs/public $(HUGO) --source docs --minify .PHONY: site-serve -site-serve: check-hugo ## Serve the documentation site with live reload +site-serve: ## Serve the documentation site with live reload # hugo server keeps the path of the configured baseURL, which would serve the site under # /decolint/ and leave http://localhost:$(SITE_PORT)/ a 404. Override it for local preview. $(HUGO) server --source docs --port $(SITE_PORT) --baseURL http://localhost:$(SITE_PORT)/ .PHONY: site-syntax -site-syntax: check-hugo ## Regenerate the syntax highlighting stylesheet +site-syntax: ## Regenerate the syntax highlighting stylesheet @{ \ echo '/* Chroma syntax highlighting token colors.'; \ echo ' Generated by `make site-syntax`; edit that target rather than this file. */'; \ @@ -69,22 +66,6 @@ site-syntax: check-hugo ## Regenerate the syntax highlighting stylesheet echo '}'; \ } > docs/assets/css/syntax.css -# The site's templates use the flat layouts/ lookup Hugo introduced in 0.146. An older hugo does -# not fail on them: it renders the pages it cannot find a layout for as empty ones, so check the -# version rather than let that reach the browser. -.PHONY: check-hugo -check-hugo: - @have=$$($(HUGO) version 2>/dev/null | sed -n 's/^hugo v\([0-9][0-9.]*\).*/\1/p'); \ - want=$(HUGO_VERSION:v%=%); \ - if [ -z "$$have" ]; then \ - echo "$(HUGO): not found; install Hugo $$want or newer, or set HUGO=/path/to/hugo" >&2; \ - exit 1; \ - fi; \ - if [ "$$(printf '%s\n%s\n' "$$want" "$$have" | sort -V | head -n 1)" != "$$want" ]; then \ - echo "hugo $$have is older than $$want, which the site's layouts need" >&2; \ - exit 1; \ - fi - .PHONY: clean clean: ## Remove build artifacts rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.hugo_build.lock .hugo_build.lock diff --git a/go.mod b/go.mod index 128fafb..5aaa45a 100644 --- a/go.mod +++ b/go.mod @@ -16,58 +16,212 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.57.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect + github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect + github.com/JohannesKaufmann/dom v0.3.1 // indirect + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.24 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/cloudfront v1.61.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.2 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bep/clocks v0.5.0 // indirect + github.com/bep/debounce v1.2.0 // indirect + github.com/bep/gitmap v1.9.0 // indirect + github.com/bep/goat v0.5.0 // indirect + github.com/bep/godartsass/v2 v2.5.0 // indirect + github.com/bep/golibsass v1.2.0 // indirect + github.com/bep/golocales v0.2.0 // indirect + github.com/bep/goportabletext v0.2.0 // indirect + github.com/bep/helpers v0.12.0 // indirect + github.com/bep/imagemeta v0.17.2 // indirect + github.com/bep/lazycache v0.8.1 // indirect + github.com/bep/logg v0.4.0 // indirect + github.com/bep/mclib v1.20401.20400 // indirect + github.com/bep/overlayfs v0.11.0 // indirect + github.com/bep/simplecobra v0.7.0 // indirect + github.com/bep/textandbinarywriter v0.1.0 // indirect + github.com/bep/tmc v0.6.0 // indirect + github.com/bits-and-blooms/bitset v1.24.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/clipperhouse/displaywidth v0.10.0 // indirect + github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/containerd/v2 v2.2.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.4 // indirect github.com/containerd/ttrpc v1.2.8 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/evanw/esbuild v0.28.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/frankban/quicktest v1.14.6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/getkin/kin-openapi v0.140.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gohugoio/gift v0.2.0 // indirect + github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 // indirect + github.com/gohugoio/go-radix v1.2.0 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect + github.com/gohugoio/httpcache v0.8.0 // indirect + github.com/gohugoio/hugo v0.164.0 // indirect + github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 // indirect + github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/google/wire v0.7.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/hairyhenderson/go-codeowners v0.7.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jdkato/prose v1.2.1 // indirect github.com/klauspost/compress v1.18.6 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/kyokomi/emoji/v2 v2.2.13 // indirect + github.com/magefile/mage v1.17.2 // indirect + github.com/makeworld-the-better-one/dither/v2 v2.4.0 // indirect + github.com/marekm4/color-extractor v1.2.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/signal v0.7.1 // indirect + github.com/muesli/smartcrop v0.3.0 // indirect + github.com/niklasfasching/go-org v1.9.1 // indirect + github.com/oasdiff/yaml v0.1.0 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.6 // indirect + github.com/olekukonko/tablewriter v1.1.4 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/fsync v0.10.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/sudo-bmitch/oci-digest v0.1.2 // indirect + github.com/tdewolff/minify/v2 v2.24.13 // indirect + github.com/tdewolff/parse/v2 v2.8.12 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect github.com/tonistiigi/fsutil v0.0.0-20260716115106-30cd4fc5d911 // indirect github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect + 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 - golang.org/x/net v0.55.0 // indirect + golang.org/x/image v0.43.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.47.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.276.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect + howett.net/plist v1.0.1 // indirect + rsc.io/qr v0.2.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect ) + +tool github.com/gohugoio/hugo diff --git a/go.sum b/go.sum index 4e07f1a..eccaf80 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,207 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= +cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= +github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= +github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.24 h1:aEDEj533yGdVvEHfkCY0D/1FbDrjnZr4pIulxRjqpHs= +github.com/aws/aws-sdk-go-v2/config v1.32.24/go.mod h1:yZtrGKJGlqfEW+/m2uTsJK+Jz7xF5R0eZfgcIG9m1ss= +github.com/aws/aws-sdk-go-v2/credentials v1.19.23 h1:Zhu3GOpRCkNjtE/gJpuPDsytSnaCCTQk8neAGsgzG5Y= +github.com/aws/aws-sdk-go-v2/credentials v1.19.23/go.mod h1:VsJF2ropPB37gDr7M2rLSpCE8IQWdpl62uae7qxZmqU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 h1:Zy6Tme1AA13kX8x3CnkHx5cqdGWGaj/anwOiWGnA0Xo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12/go.mod h1:ql4uXYKoTM9WUAUSmthY4AtPVrlTBZOvnBJTiCUdPxI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/cloudfront v1.61.1 h1:LSv6jOIn/yEsGLeL4TLggsLA+I+XbuZ8sKmUIEWKrzI= +github.com/aws/aws-sdk-go-v2/service/cloudfront v1.61.1/go.mod h1:XUduecWr236DyG8nZwJMewFbS4QcL8NZHxohdYDoPhM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.5 h1:6Xt6Ztjkwdia/7EtEaG7ki/qZUYlCcd7tGUotQed1QE= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.5/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= +github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= +github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= +github.com/bep/debounce v1.2.0 h1:wXds8Kq8qRfwAOpAxHrJDbCXgC5aHSzgQb/0gKsHQqo= +github.com/bep/debounce v1.2.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc= +github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo= +github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA= +github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c= +github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg= +github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= +github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI= +github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= +github.com/bep/golocales v0.2.0 h1:4H1H5UPw3ainpj5zykeEfiMRQngyaIC/t+I4Dvn+fvE= +github.com/bep/golocales v0.2.0/go.mod h1:Hl78nje8mNL3LzLeJvYN9NsIZgyFJGrGfvgO9r1+mwE= +github.com/bep/goportabletext v0.2.0 h1:CZ9f8jADBWqHwBymQiJJPCTSV/tHSA+PYzlUf86Yze0= +github.com/bep/goportabletext v0.2.0/go.mod h1:xDeA5+qcgKzJq6Q6XjAiBKtxLD3Yn7f6XP4joD3J3qU= +github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY= +github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps= +github.com/bep/imagemeta v0.17.2 h1:fDyXM1eAqCfBeqGLqS6UsN4OfuLM0cdu70KuLCehjOg= +github.com/bep/imagemeta v0.17.2/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA= +github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg= +github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w= +github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ= +github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0= +github.com/bep/mclib v1.20401.20400 h1:silTOMNlNI7yHBb+HxEE0THIVFVWo/0I4SCH69FxtmU= +github.com/bep/mclib v1.20401.20400/go.mod h1:v5Hh3EIinPn7epigP28uf9JCkZlYzBS2vEOPe2wrHzM= +github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs= +github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc= +github.com/bep/simplecobra v0.7.0 h1:kG8ZPwEc1o96hlIVGXcrrvwC8RornBqvMD3+pS0Z7y0= +github.com/bep/simplecobra v0.7.0/go.mod h1:PDXvBWH1ZMX05DRQ25ub/C6kKUuq+jROPgjbVz8wO1g= +github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po= +github.com/bep/textandbinarywriter v0.1.0/go.mod h1:dAcHveajlWWU7PXhp6Dn4PIAYDg2H13Huif9xMS2w8w= +github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s= +github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0= +github.com/bits-and-blooms/bitset v1.24.5 h1:654xBVHc23gJMAgOTkPNoCVfiRxuIOAUnAZFtopqJ4w= +github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= +github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/compose-spec/compose-go/v2 v2.13.0 h1:2+2oS3v4SrtAOBdZRAZYBsBy47D571p5EXMSCppmTtE= @@ -20,43 +218,237 @@ github.com/containerd/ttrpc v1.2.8 h1:xbVu6D4qF2jihdh9rDVOKqUMiFBQk6YctTdo1zk087 github.com/containerd/ttrpc v1.2.8/go.mod h1:wyZW2K79t4Hfcxl+GUvkZqRBzJlqFFvgEeeWXa42tyE= github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc= +github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= +github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc= +github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4= +github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I= +github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6/go.mod h1:m5hu1im5Qc7LDycVLvee6MPobJiRLBYHklypFJR0/aE= +github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yrhg= +github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU= +github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI= +github.com/gohugoio/hugo v0.164.0 h1:oDElnVJNvDMpEOeoxBjzW8JTFk7u+gJj2kG74BsfXlM= +github.com/gohugoio/hugo v0.164.0/go.mod h1:QPBCN6Gntmm6YrYJEnTasaZjnMLRkv8YoyCOw7mo9JE= +github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus= +github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs= +github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI= +github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= +github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= +github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= +github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo= +github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= +github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= +github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= +github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= +github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= +github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/buildkit v0.31.2 h1:UsaoSa0z45ycj+8DqWwmiXKnszDSNvv3wYPGLVtcEKE= github.com/moby/buildkit v0.31.2/go.mod h1:q1QO35x5YryiXLONScL2IybwMIziz6Rq3FBznz8fmGc= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -69,34 +461,112 @@ github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= +github.com/montanaflynn/stats v0.6.3/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= +github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= +github.com/neurosnap/sentences v1.0.6/go.mod h1:pg1IapvYpWCJJm/Etxeh0+gtMf1rI1STY9S7eUCPbDc= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= +github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/olareg/olareg v0.2.2 h1:H29V0+V2bIW4nRF3B1QAvooXts5eYS9dmQsD/chrBf0= github.com/olareg/olareg v0.2.2/go.mod h1:NxhWTjHaFU51m1+kwi2MZnakJlITH0s1tzdzCT3dXRQ= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= +github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= +github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shogo82148/go-shuffle v0.0.0-20180218125048-27e6095f230d/go.mod h1:2htx6lmL0NGLHlO8ZCf+lQBGBHIbEujyywxJArf+2Yc= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/fsync v0.10.1 h1:JRnB7G72b+gIBaBcpn5ibJSd7ww1iEahXSX2B8G6dSE= +github.com/spf13/fsync v0.10.1/go.mod h1:y+B41vYq5i6Boa3Z+BVoPbDeOvxVkNU5OBXhoT8i4TQ= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/sudo-bmitch/oci-digest v0.1.2 h1:are0qzWTsFZGZ3Uvdi9OSztJszSWaab6iqquMEEB7rw= github.com/sudo-bmitch/oci-digest v0.1.2/go.mod h1:SH6l5OIe0islKBZBedjiPOeET/0QwGL+/oYfQt51uQo= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= +github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= +github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg= +github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= +github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4= github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY= github.com/tonistiigi/fsutil v0.0.0-20260716115106-30cd4fc5d911 h1:xJZz1fhsRSrGTzQ6wvh1gX6d5jQaYjbIuGXT2s0AWuM= @@ -105,8 +575,24 @@ github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10 github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk= @@ -115,45 +601,371 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= +go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY= +gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= +google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/neurosnap/sentences.v1 v1.0.6/go.mod h1:YlK+SN+fLQZj+kY3r8DkGDhDr91+S3JmTb5LSxFRQo0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= +software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= From 01a7206e2cd74bd142b5d89cc733f3667e0ff62f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:50:28 +0000 Subject: [PATCH 10/24] fix(lint): use strings.SplitSeq instead of strings.Split Fixes the modernize:stringsseq finding golangci-lint raised on the two line-splitting loops added for parsing rule doc pages. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- rules/doc_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rules/doc_test.go b/rules/doc_test.go index f4a0dd8..70ae314 100644 --- a/rules/doc_test.go +++ b/rules/doc_test.go @@ -234,7 +234,7 @@ func parseDocsPage(t *testing.T, name, src string) docsPage { var section, fileName, fence string var block strings.Builder - for _, line := range strings.Split(body, "\n") { + for line := range strings.SplitSeq(body, "\n") { if fence != "" { if strings.TrimSpace(line) == fence { if fileName != "" { @@ -283,7 +283,7 @@ func parseFrontMatter(t *testing.T, name, src string) map[string]string { out := map[string]string{} var folding string - for _, line := range strings.Split(src, "\n") { + for line := range strings.SplitSeq(src, "\n") { if folding != "" { if strings.HasPrefix(line, " ") { out[folding] = strings.TrimSpace(out[folding] + " " + strings.TrimSpace(line)) From dc2bccd34afe0dfa089c0f008a0670d3ab47cd79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 10:58:49 +0000 Subject: [PATCH 11/24] feat: generate the documentation site and README rules table from source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule documentation moves onto linter.Rule itself (LongDescription, References, Example) instead of hand-written pages under docs/content/rules/ — the same information the earlier design kept out of the binary to save ~13KB, now brought back because it drives real tooling: "decolint -rules -format=json" exposes the full catalog (format/rules.go), and a new cmd/docgen program turns it into the site. Example is machine-checked like the pages it replaces were: Bad must still lint to a finding and Good to none (rules/doc_test.go), now against the struct directly. The two rules that could not previously be verified (install.sh's executable bit, its absence) now can, since ExampleFile carries a file mode and fstest.MapFile does too. cmd/docgen also splits README.md into the landing, Getting started and Reference site pages at the same headings the README already uses, rewriting anchors that now point across pages (e.g. Getting started's "(#config-file)" becomes "(reference.md#config-file)"), and rewrites README's own rules table between markers from the same rules.Builtin() data. Nothing it produces is committed (docs/.generated/, gitignored); "make site" and "make site-serve" run it first, and CI's new docs job fails if running it changes README.md, to catch a rule or the generator drifting from the other. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- .github/workflows/ci.yml | 17 + .github/workflows/pages.yml | 8 + .gitignore | 5 + CONTRIBUTING.md | 143 ++++--- Makefile | 10 +- README.md | 3 +- cmd/decolint/format.go | 57 +++ cmd/decolint/main.go | 45 ++- cmd/decolint/main_test.go | 102 +++++ cmd/docgen/main.go | 42 ++ cmd/docgen/main_test.go | 73 ++++ cmd/docgen/readme.go | 201 ++++++++++ cmd/docgen/readme_test.go | 137 +++++++ cmd/docgen/rules.go | 174 ++++++++ cmd/docgen/rules_test.go | 249 ++++++++++++ cmd/docgen/slug.go | 69 ++++ cmd/docgen/slug_test.go | 64 +++ docs/assets/css/main.css | 21 + docs/content/_index.md | 76 ---- docs/content/getting-started.md | 278 ------------- .../rules/conflicting-container-def.md | 44 --- .../feature-install-script-not-executable.md | 38 -- docs/content/rules/id-dir-mismatch.md | 44 --- docs/content/rules/invalid-semver.md | 40 -- .../content/rules/missing-build-dockerfile.md | 42 -- docs/content/rules/missing-compose-service.md | 41 -- docs/content/rules/missing-container-def.md | 39 -- .../rules/missing-feature-install-script.md | 39 -- docs/content/rules/missing-required-props.md | 39 -- .../rules/missing-workspace-mount-folder.md | 40 -- docs/content/rules/no-app-port.md | 38 -- docs/content/rules/no-bind-mount.md | 51 --- docs/content/rules/no-cap-add-all.md | 40 -- docs/content/rules/no-docker-socket-mount.md | 49 --- docs/content/rules/no-host-port-format.md | 39 -- docs/content/rules/no-image-latest.md | 35 -- docs/content/rules/no-privileged-container.md | 45 --- docs/content/rules/no-seccomp-override.md | 44 --- docs/content/rules/no-seccomp-unconfined.md | 40 -- docs/content/rules/pin-extension-version.md | 47 --- docs/content/rules/pin-feature-version.md | 43 -- docs/content/rules/pin-image-digest.md | 37 -- docs/content/rules/require-cap-drop-all.md | 44 --- .../rules/require-no-new-privileges.md | 39 -- docs/content/rules/require-non-root.md | 43 -- .../rules/undefined-template-option.md | 76 ---- docs/content/rules/unused-template-option.md | 74 ---- docs/hugo.toml | 27 ++ docs/layouts/page.html | 3 + format/rules.go | 41 ++ linter/rule.go | 48 +++ rules/conflicting_container_def.go | 41 +- rules/doc_test.go | 370 ++++-------------- .../feature_install_script_not_executable.go | 43 +- rules/id_dir_mismatch.go | 42 +- rules/invalid_semver.go | 37 +- rules/missing_build_dockerfile.go | 40 +- rules/missing_compose_service.go | 38 +- rules/missing_container_def.go | 36 +- rules/missing_feature_install_script.go | 30 +- rules/missing_required_props.go | 36 +- rules/missing_workspace_mount_folder.go | 37 +- rules/no_app_port.go | 36 +- rules/no_bind_mount.go | 50 ++- rules/no_cap_add_all.go | 36 +- rules/no_docker_socket_mount.go | 45 ++- rules/no_host_port_format.go | 38 +- rules/no_image_latest.go | 33 +- rules/no_privileged_container.go | 41 +- rules/no_seccomp_override.go | 39 +- rules/no_seccomp_unconfined.go | 36 +- rules/pin_extension_version.go | 46 ++- rules/pin_feature_version.go | 40 +- rules/pin_image_digest.go | 34 +- rules/require_cap_drop_all.go | 40 +- rules/require_no_new_privileges.go | 35 +- rules/require_non_root.go | 39 +- rules/undefined_template_option.go | 72 +++- rules/unused_template_option.go | 71 +++- 79 files changed, 2522 insertions(+), 2042 deletions(-) create mode 100644 cmd/docgen/main.go create mode 100644 cmd/docgen/main_test.go create mode 100644 cmd/docgen/readme.go create mode 100644 cmd/docgen/readme_test.go create mode 100644 cmd/docgen/rules.go create mode 100644 cmd/docgen/rules_test.go create mode 100644 cmd/docgen/slug.go create mode 100644 cmd/docgen/slug_test.go delete mode 100644 docs/content/_index.md delete mode 100644 docs/content/getting-started.md delete mode 100644 docs/content/rules/conflicting-container-def.md delete mode 100644 docs/content/rules/feature-install-script-not-executable.md delete mode 100644 docs/content/rules/id-dir-mismatch.md delete mode 100644 docs/content/rules/invalid-semver.md delete mode 100644 docs/content/rules/missing-build-dockerfile.md delete mode 100644 docs/content/rules/missing-compose-service.md delete mode 100644 docs/content/rules/missing-container-def.md delete mode 100644 docs/content/rules/missing-feature-install-script.md delete mode 100644 docs/content/rules/missing-required-props.md delete mode 100644 docs/content/rules/missing-workspace-mount-folder.md delete mode 100644 docs/content/rules/no-app-port.md delete mode 100644 docs/content/rules/no-bind-mount.md delete mode 100644 docs/content/rules/no-cap-add-all.md delete mode 100644 docs/content/rules/no-docker-socket-mount.md delete mode 100644 docs/content/rules/no-host-port-format.md delete mode 100644 docs/content/rules/no-image-latest.md delete mode 100644 docs/content/rules/no-privileged-container.md delete mode 100644 docs/content/rules/no-seccomp-override.md delete mode 100644 docs/content/rules/no-seccomp-unconfined.md delete mode 100644 docs/content/rules/pin-extension-version.md delete mode 100644 docs/content/rules/pin-feature-version.md delete mode 100644 docs/content/rules/pin-image-digest.md delete mode 100644 docs/content/rules/require-cap-drop-all.md delete mode 100644 docs/content/rules/require-no-new-privileges.md delete mode 100644 docs/content/rules/require-non-root.md delete mode 100644 docs/content/rules/undefined-template-option.md delete mode 100644 docs/content/rules/unused-template-option.md create mode 100644 format/rules.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aebe1b..bebb9ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,23 @@ jobs: go-version-file: go.mod - run: make lint + docs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + # cmd/docgen regenerates the site's content from rules/*.go and README.md, and rewrites + # README's rules table from the same rule catalog; a diff here means one of those drifted + # from the other and a commit forgot to run "make site-content". + - run: make site-content + - run: git diff --exit-code README.md + dogfooding: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index eb7872c..dff4e38 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,11 +7,19 @@ on: branches: [main] paths: - docs/** + - rules/** + - linter/rule.go + - cmd/docgen/** + - README.md - Makefile - .github/workflows/pages.yml pull_request: paths: - docs/** + - rules/** + - linter/rule.go + - cmd/docgen/** + - README.md - Makefile - .github/workflows/pages.yml workflow_dispatch: diff --git a/.gitignore b/.gitignore index fb93f34..4a85103 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,13 @@ profile.cov # Build output /bin/ +# A stray "go build ./cmd/docgen/..." from the repo root lands here; the Makefile always uses +# "go run" for it instead, so this is never an intentional artifact. +/docgen /docs/public/ /docs/resources/ +# Rule pages and the README-derived pages; see cmd/docgen and "make site-content". +/docs/.generated/ # Hugo writes its build lock in the directory it runs from, which is not always docs/. .hugo_build.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 135cd3b..6011277 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,17 +44,16 @@ and calls `Check` for every value matching one of the paths; a `*` segment matches any object member name or array index, and the empty string matches the document root. -The rule itself carries only the one-line `Description` of what it -checks; the reasoning and the examples live on the documentation site -(see [Documenting a rule](#documenting-a-rule) below), which every -finding links to. - A rule's default severity is not set individually; it comes entirely from its category (see `categoryDefaultSeverities` in [`rules.go`](rules/rules.go)) — only `CategoryCorrectness` runs by default, at `error`. Pick the category that matches the problem the rule reports, not the severity you'd like it to have. +Besides the short `Description`, a rule carries the reasoning and an +example directly on the [`linter.Rule`](linter/rule.go) value — +nothing about a rule lives in a separate file: + ```go package rules @@ -63,11 +62,28 @@ import "github.com/bare-devcontainer/decolint/linter" var MyRule = &linter.Rule{ ID: "my-rule", Description: "...", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: nil, // applies to every platform - Paths: []string{"/mounts/*"}, - Check: checkMyRule, + LongDescription: `What goes wrong in the configuration this reports, and what to do +instead.`, + References: []string{"https://containers.dev/implementors/json_reference/"}, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: nil, // applies to every platform + Paths: []string{"/mounts/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer.json", Content: `{ ... } +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer.json", Content: `{ ... } +`}, + }, + }, + }, + Check: checkMyRule, } func checkMyRule(ctx *linter.Context, node *linter.Node) []linter.Finding { @@ -78,71 +94,50 @@ func checkMyRule(ctx *linter.Context, node *linter.Node) []linter.Finding { } ``` -The existing rules in [`rules/`](rules/) are good references, -including for the table-driven tests each rule ships with. When a new -rule lands, also add a row for it to the table in -[README.md](README.md#rules). - -## Documenting a rule - -Every rule has a page under [`docs/content/rules/`](docs/content/rules/), -named after its ID, which is where the reasoning and the examples live. -It is what the SARIF output, and so every code scanning alert, links -to, so write it for the user who just hit the finding. - -````markdown ---- -title: my-rule -category: correctness -platforms: [] -file_types: [devcontainer] -description: >- - disallow ... ---- - -## Why - -What goes wrong in the configuration this reports, and what to do -instead. - -## Bad - -```jsonc -{ ... } -``` - -## Good - -```jsonc -{ ... } -``` - -## References +`LongDescription` is Markdown; write it for the user who just hit the +finding, since `decolint -rules -format=json` and the SARIF output +both carry it, and it is what the documentation site is built from +(see below). + +`Example` is machine-checked, not just illustrative: +[`rules/doc_test.go`](rules/doc_test.go) lints `Bad` with the rule as +the only one active and requires a finding, then lints `Good` and +requires none. `Snippet.Files` is one directory: the file named after +the rule's first `FileTypes` entry (`devcontainer.json`, +`devcontainer-feature.json`, or `devcontainer-template.json`) is the +one linted, and any other files are context a rule reads from the +directory (e.g. a Template's other files, for a +`${templateOption:...}` reference). Set `Snippet.DirName` when the +rule reads the directory's own name (`id-dir-mismatch`), and a file's +`Mode` when the rule reads permission bits (`install.sh`'s executable +bit) — `Bad` and `Good` can then differ in mode alone, with identical +content. `Example.Note` is optional Markdown shown after `Good`, for +context the two snippets alone don't convey. -- -```` - -The front matter must match the rule's Go declaration, and the tests -in [`rules/doc_test.go`](rules/doc_test.go) enforce it: they lint the -Bad example and require it to report the rule, lint the Good example -and require it to report nothing, and check that every rule has a page -and every page at least one `https` reference. - -Two things to know when writing the examples: - -- An example needing more than one file names each with a - ``### `path` `` heading before its block; without one, a block is - linted as the rule's own file type. Set `example_dir` in the front - matter when the rule reads the name of the directory it is in. -- An example whose Bad and Good differ in something other than file - contents — a permission bit, or a missing file — cannot be linted. - Write it in whatever form shows the difference, set - `example_verify: false`, and add the rule to `unverifiableExamples` - in the test. - -The rule index and the sidebar are built from the pages themselves, so -there is no list to update by hand. Preview the site with `make -site-serve`. +The existing rules in [`rules/`](rules/) are good references, +including for the table-driven tests each rule ships with. + +## The documentation site and the README rules table + +Both are generated from `rules/*.go` and `README.md` by +[`cmd/docgen`](cmd/docgen/), run as part of `make site` (see +[Development](#development) above) and standalone as `make +site-content`. Nothing under `docs/content/rules/` other than +`_index.md`, and nothing in `README.md` between the +`` markers, is hand-edited — a new rule +or a changed `LongDescription`/`Example`/`References` needs no +follow-up edit anywhere else. CI's `docs` job runs `make site-content` +and fails if that changes `README.md`, which is what catches a +generator or a rule declaration that drifted from the other. + +`README.md` itself is also this generator's input for the rest of the +site: the landing page, Getting started, and Reference are the +corresponding sections of `README.md`, split at their headings. A +link within README.md to a heading that ends up on a different page +(e.g. Getting started linking to `#config-file`, which lives on +Reference) is rewritten to point there; write new cross-references the +same way you already do (`[Config file](#config-file)`) and the +generator will resolve them. When implementing or reviewing rules, consult the Dev Container specification at [containers.dev](https://containers.dev/) to confirm diff --git a/Makefile b/Makefile index 2784211..156f3f0 100644 --- a/Makefile +++ b/Makefile @@ -45,15 +45,19 @@ lint: ## Run all lint rules go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run .PHONY: site -site: ## Build the documentation site into docs/public +site: site-content ## Build the documentation site into docs/public $(HUGO) --source docs --minify .PHONY: site-serve -site-serve: ## Serve the documentation site with live reload +site-serve: site-content ## Serve the documentation site with live reload # hugo server keeps the path of the configured baseURL, which would serve the site under # /decolint/ and leave http://localhost:$(SITE_PORT)/ a 404. Override it for local preview. $(HUGO) server --source docs --port $(SITE_PORT) --baseURL http://localhost:$(SITE_PORT)/ +.PHONY: site-content +site-content: ## Regenerate the rule pages, README-derived pages, and README rules table + go run ./cmd/docgen + .PHONY: site-syntax site-syntax: ## Regenerate the syntax highlighting stylesheet @{ \ @@ -68,7 +72,7 @@ site-syntax: ## Regenerate the syntax highlighting stylesheet .PHONY: clean clean: ## Remove build artifacts - rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.hugo_build.lock .hugo_build.lock + rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.generated docs/.hugo_build.lock .hugo_build.lock .PHONY: install install: ## Install the decolint binary to GOPATH/bin diff --git a/README.md b/README.md index 165d15f..b9c96e3 100644 --- a/README.md +++ b/README.md @@ -603,7 +603,7 @@ Only `correctness` runs by default; the rest are `off` until enabled: - `style` (default `off`) — discouraged or legacy configuration that still works. - + | ID | Category | Platform | Description | | --- | --- | --- | --- | | [`conflicting-container-def`](https://bare-devcontainer.github.io/decolint/rules/conflicting-container-def/) | `correctness` | (all) | disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile" | @@ -633,6 +633,7 @@ Only `correctness` runs by default; the rest are `off` until enabled: | [`pin-image-digest`](https://bare-devcontainer.github.io/decolint/rules/pin-image-digest/) | `reproducibility` | (all) | disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...") | | [`no-app-port`](https://bare-devcontainer.github.io/decolint/rules/no-app-port/) | `style` | (all) | disallow the legacy "appPort" property in favor of "forwardPorts" | | [`unused-template-option`](https://bare-devcontainer.github.io/decolint/rules/unused-template-option/) | `style` | (all) | disallow a Template option that no file in the Template references | + ## Suppressing findings diff --git a/cmd/decolint/format.go b/cmd/decolint/format.go index 9a16e00..b4f4604 100644 --- a/cmd/decolint/format.go +++ b/cmd/decolint/format.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/bare-devcontainer/decolint/format" + "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" ) @@ -49,3 +50,59 @@ func sarifRules(cfg Config) []format.SARIFRule { } return out } + +// ruleDocs adapts every built-in rule, and the severity cfg currently gives it, into the shape +// "decolint -rules -format=json" and cmd/docgen consume, so neither package depends on the rules +// package. +func ruleDocs(cfg Config) []format.RuleDoc { + overrides := rules.Overrides{Categories: cfg.Categories, Rules: cfg.Rules} + builtin := rules.Builtin() + out := make([]format.RuleDoc, len(builtin)) + for i, reg := range builtin { + out[i] = format.RuleDoc{ + ID: reg.Rule.ID, + Description: reg.Rule.Description, + LongDescription: reg.Rule.LongDescription, + References: reg.Rule.References, + Category: reg.Rule.Category.String(), + Platforms: platformStrings(reg.Rule.Platforms), + FileTypes: fileTypeStrings(reg.Rule.FileTypes), + Example: ruleExample(reg.Rule.Example), + DocsURL: rules.DocsURL(reg.Rule.ID), + Severity: overrides.SeverityFor(reg).String(), + } + } + return out +} + +func platformStrings(platforms []linter.Platform) []string { + out := make([]string, len(platforms)) + for i, p := range platforms { + out[i] = p.String() + } + return out +} + +func fileTypeStrings(fileTypes []linter.FileType) []string { + out := make([]string, len(fileTypes)) + for i, ft := range fileTypes { + out[i] = string(ft) + } + return out +} + +func ruleExample(ex linter.Example) format.RuleExample { + return format.RuleExample{ + Bad: ruleSnippet(ex.Bad), + Good: ruleSnippet(ex.Good), + Note: ex.Note, + } +} + +func ruleSnippet(s linter.Snippet) format.RuleSnippet { + files := make([]format.RuleExampleFile, len(s.Files)) + for i, f := range s.Files { + files[i] = format.RuleExampleFile{Path: f.Path, Content: f.Content, Mode: uint32(f.Mode)} + } + return format.RuleSnippet{Files: files, DirName: s.DirName} +} diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index fc3e7c0..6ff4c9e 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -2,7 +2,9 @@ package main import ( + "bytes" "context" + "encoding/json/v2" "errors" "flag" "fmt" @@ -123,12 +125,30 @@ var severityEmoji = map[linter.Severity]string{ // rulesTableHeader is the header row of the -rules Markdown table. var rulesTableHeader = []string{"Rule ID", "Category", "Platform", "Current"} -// listRules writes a Markdown table of the built-in rules to output: each rule's ID, category, -// target platforms (or "(all)"), and current severity (its category's default, overridden by cfg -// if any), in the order rules.Builtin returns them. A rule's default severity is not listed -// separately since it is uniform within a category; see the README's Rule categories section. -// Columns are padded to a common width so the raw Markdown source itself reads as an aligned table. +// listRules writes the built-in rules to output, in the format cfg.Format names: +// - "" or "text" (the default): a Markdown table of each rule's ID, category, target platforms +// (or "(all)"), and current severity (its category's default, overridden by cfg if any). A +// rule's default severity is not listed separately since it is uniform within a category; see +// the README's Rule categories section. +// - "json": the full catalog (description, rationale, references, example, docs address, +// current severity), for tooling — cmd/docgen builds the documentation site from it. +// +// "github" and "sarif" describe lint findings, not the rule catalog itself, so they are an error +// here. func listRules(output io.Writer, cfg Config) error { + switch strings.ToLower(cfg.Format) { + case "", "text": + return listRulesText(output, cfg) + case "json": + return listRulesJSON(output, cfg) + default: + return fmt.Errorf("unknown format %q for -rules (want one of: text, json)", cfg.Format) + } +} + +// listRulesText writes a Markdown table of the built-in rules to output; see [listRules]. Columns +// are padded to a common width so the raw Markdown source itself reads as an aligned table. +func listRulesText(output io.Writer, cfg Config) error { overrides := rules.Overrides{Categories: cfg.Categories, Rules: cfg.Rules} rows := [][]string{rulesTableHeader} for _, reg := range rules.Builtin() { @@ -159,6 +179,21 @@ func listRules(output io.Writer, cfg Config) error { return nil } +// listRulesJSON writes the full rule catalog (see [format.RuleDoc]) to output as a JSON array, one +// entry per built-in rule in rules.Builtin order. It marshals into an in-memory buffer first so +// that a failure never leaves partial JSON on output. +func listRulesJSON(output io.Writer, cfg Config) error { + var buf bytes.Buffer + if err := json.MarshalWrite(&buf, ruleDocs(cfg)); err != nil { + return fmt.Errorf("marshal rules: %w", err) + } + buf.WriteByte('\n') + if _, err := output.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write rules: %w", err) + } + return nil +} + // platformNames renders the platforms a rule targets, separated by sep. A rule that targets none // applies to every platform, which is shown as "(all)". func platformNames(platforms []linter.Platform, sep string) string { diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index e3f41cf..e861c68 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -452,6 +452,108 @@ func TestRun_Flags(t *testing.T) { } }) + t.Run("-rules -format=json", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + exitCode := run(t.Context(), []string{"-rules", "-format=json"}, &stdout, &stderr, emptyEnv) + if exitCode != 0 { + t.Errorf("exit code = %d, want 0", exitCode) + } + if stderr.String() != "" { + t.Errorf("stderr = %q, want empty", stderr.String()) + } + + var got []format.RuleDoc + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("unmarshal -rules -format=json output: %v", err) + } + + builtin := rules.Builtin() + if len(got) != len(builtin) { + t.Fatalf("got %d rule(s), want %d", len(got), len(builtin)) + } + + i := slices.IndexFunc(got, func(d format.RuleDoc) bool { return d.ID == "no-privileged-container" }) + if i < 0 { + t.Fatalf("no-privileged-container missing from -rules -format=json output") + } + ri := slices.IndexFunc(builtin, func(reg rules.Registration) bool { return reg.Rule.ID == "no-privileged-container" }) + if ri < 0 { + t.Fatalf("no built-in rule with ID %q", "no-privileged-container") + } + rule := builtin[ri].Rule + want := format.RuleDoc{ + ID: rule.ID, + Description: rule.Description, + LongDescription: rule.LongDescription, + References: rule.References, + Category: rule.Category.String(), + Platforms: []string{}, + FileTypes: []string{"devcontainer", "feature"}, + DocsURL: rules.DocsURL(rule.ID), + Severity: linter.SeverityOff.String(), + Example: format.RuleExample{ + Bad: format.RuleSnippet{ + Files: []format.RuleExampleFile{ + {Path: rule.Example.Bad.Files[0].Path, Content: rule.Example.Bad.Files[0].Content}, + }, + }, + Good: format.RuleSnippet{ + Files: []format.RuleExampleFile{ + {Path: rule.Example.Good.Files[0].Path, Content: rule.Example.Good.Files[0].Content}, + }, + }, + Note: rule.Example.Note, + }, + } + if diff := cmp.Diff(want, got[i]); diff != "" { + t.Errorf("no-privileged-container doc mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("-rules -format=json carries example file mode", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + exitCode := run(t.Context(), []string{"-rules", "-format=json"}, &stdout, &stderr, emptyEnv) + if exitCode != 0 { + t.Errorf("exit code = %d, want 0", exitCode) + } + + var got []format.RuleDoc + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("unmarshal -rules -format=json output: %v", err) + } + i := slices.IndexFunc(got, func(d format.RuleDoc) bool { return d.ID == "feature-install-script-not-executable" }) + if i < 0 { + t.Fatalf("feature-install-script-not-executable missing from -rules -format=json output") + } + j := slices.IndexFunc(got[i].Example.Good.Files, func(f format.RuleExampleFile) bool { return f.Path == "install.sh" }) + if j < 0 { + t.Fatalf("Good example has no install.sh file") + } + if got[i].Example.Good.Files[j].Mode != 0o755 { + t.Errorf("Good install.sh mode = %#o, want 0755", got[i].Example.Good.Files[j].Mode) + } + }) + + t.Run("-rules -format=sarif is an error", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + exitCode := run(t.Context(), []string{"-rules", "-format=sarif"}, &stdout, &stderr, emptyEnv) + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } + if stdout.String() != "" { + t.Errorf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "sarif") { + t.Errorf("stderr = %q, want it to mention the unsupported format", stderr.String()) + } + }) + t.Run("-help", func(t *testing.T) { t.Parallel() diff --git a/cmd/docgen/main.go b/cmd/docgen/main.go new file mode 100644 index 0000000..4ac7b08 --- /dev/null +++ b/cmd/docgen/main.go @@ -0,0 +1,42 @@ +// Command docgen generates the documentation site's content and the README's rules table from +// rules/*.go and README.md, so neither has to be hand-kept in sync with the other. It is not part of +// the decolint binary; "make site" runs it before Hugo builds the site (see the Makefile). +package main + +import ( + "fmt" + "os" +) + +// generatedContentDir is where the generated site pages are written. It is mounted alongside +// docs/content in hugo.toml and is never committed (see .gitignore); docs/content/rules/_index.md +// is the only rules page that stays hand-written. +const generatedContentDir = "docs/.generated/content" + +func main() { + if err := run("README.md", generatedContentDir); err != nil { + fmt.Fprintln(os.Stderr, "docgen:", err) + os.Exit(1) + } +} + +// run regenerates everything docgen owns: the rule pages and the README-derived pages into +// contentDir, and the rules table in the README at readmePath, in place. +func run(readmePath, contentDir string) error { + if err := os.RemoveAll(contentDir); err != nil { + return fmt.Errorf("clean %s: %w", contentDir, err) + } + if err := os.MkdirAll(contentDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", contentDir, err) + } + if err := writeRulePages(contentDir); err != nil { + return fmt.Errorf("generate rule pages: %w", err) + } + if err := writeReadmePages(readmePath, contentDir); err != nil { + return fmt.Errorf("generate pages from %s: %w", readmePath, err) + } + if err := updateReadmeRulesTable(readmePath); err != nil { + return fmt.Errorf("update rules table in %s: %w", readmePath, err) + } + return nil +} diff --git a/cmd/docgen/main_test.go b/cmd/docgen/main_test.go new file mode 100644 index 0000000..26307c9 --- /dev/null +++ b/cmd/docgen/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bare-devcontainer/decolint/rules" +) + +func TestRun(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + readmePath := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmePath, []byte(fixtureReadme), 0o644); err != nil { + t.Fatalf("write fixture README: %v", err) + } + contentDir := filepath.Join(dir, "content") + + if err := run(readmePath, contentDir); err != nil { + t.Fatalf("run: %v", err) + } + + for _, name := range []string{"_index.md", "getting-started.md", "reference.md"} { + if _, err := os.Stat(filepath.Join(contentDir, name)); err != nil { + t.Errorf("run did not create %s: %v", name, err) + } + } + entries, err := os.ReadDir(filepath.Join(contentDir, "rules")) + if err != nil { + t.Fatalf("read rules dir: %v", err) + } + if len(entries) != len(rules.Builtin()) { + t.Errorf("run wrote %d rule page(s), want %d", len(entries), len(rules.Builtin())) + } + + got, err := os.ReadFile(readmePath) + if err != nil { + t.Fatalf("read %s: %v", readmePath, err) + } + if string(got) == fixtureReadme { + t.Error("run did not touch the README's rules table (fixture has a stale placeholder row)") + } +} + +// TestRun_RegeneratesCleanly checks that run replaces contentDir's previous output rather than +// merely adding to it: a stale file left over from a since-removed rule must not survive a rerun. +func TestRun_RegeneratesCleanly(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + readmePath := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmePath, []byte(fixtureReadme), 0o644); err != nil { + t.Fatalf("write fixture README: %v", err) + } + contentDir := filepath.Join(dir, "content") + + stale := filepath.Join(contentDir, "rules", "stale-rule.md") + if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { + t.Fatalf("create %s: %v", filepath.Dir(stale), err) + } + if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil { + t.Fatalf("write %s: %v", stale, err) + } + + if err := run(readmePath, contentDir); err != nil { + t.Fatalf("run: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("run left %s behind, want it removed", stale) + } +} diff --git a/cmd/docgen/readme.go b/cmd/docgen/readme.go new file mode 100644 index 0000000..db0fabe --- /dev/null +++ b/cmd/docgen/readme.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// readme.go turns README.md into the three top-level site pages that mirror it — the landing page, +// Getting started, and Reference — so their prose has exactly one source. Splitting it means +// rewriting the anchor links the README uses to jump within itself: an anchor now on a different +// page must point there instead ("reference.md#config-file"), which the goldmark link render hook +// enabled in hugo.toml resolves to that page's real address. + +// README section boundaries, matched as exact heading lines. A generator failing loudly when README +// is restructured is the right failure mode; silently misplacing content is not. +const ( + readmeWhyHeading = "## Why decolint" + readmeTryHeading = "## Try it" + readmeLintingHeading = "## Linting a Feature or Template" + readmeReferenceH1 = "# Reference" + readmeContribHeading = "## Contributing" + readmeHorizontalRule = "---" + landingDescription = "A linter for Dev Container configuration files." + gettingStartedSummary = "Install decolint, choose what it reports, and wire it into CI." + referenceSummary = "Every flag, config file member, and output format decolint supports." +) + +// writeReadmePages splits the README at readmePath into the landing, getting-started and reference +// pages and writes them under dir. +func writeReadmePages(readmePath, dir string) error { + src, err := os.ReadFile(readmePath) + if err != nil { + return fmt.Errorf("read %s: %w", readmePath, err) + } + pages, err := splitReadme(string(src)) + if err != nil { + return fmt.Errorf("%s: %w", readmePath, err) + } + for name, page := range pages { + path := filepath.Join(dir, name+".md") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(page), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} + +// splitReadme splits README.md's content src into the landing, getting-started and reference +// pages, keyed by output file name (without extension), each a complete page: front matter, then +// body with cross-page anchors rewritten (see rewriteAnchors). +func splitReadme(src string) (map[string]string, error) { + lines := strings.Split(src, "\n") + + whyIdx := findHeadingLine(lines, readmeWhyHeading) + tryIdx := findHeadingLine(lines, readmeTryHeading) + lintingIdx := findHeadingLine(lines, readmeLintingHeading) + refIdx := findHeadingLine(lines, readmeReferenceH1) + contribIdx := findHeadingLine(lines, readmeContribHeading) + if whyIdx < 0 || tryIdx < 0 || lintingIdx < 0 || refIdx < 0 || contribIdx < 0 { + return nil, fmt.Errorf("could not find all expected section headings (why=%d try=%d linting=%d reference=%d contributing=%d)", + whyIdx, tryIdx, lintingIdx, refIdx, contribIdx) + } + if !(whyIdx < tryIdx && tryIdx < lintingIdx && lintingIdx < refIdx && refIdx < contribIdx) { + return nil, fmt.Errorf("section headings are not in the expected order (why=%d try=%d linting=%d reference=%d contributing=%d)", + whyIdx, tryIdx, lintingIdx, refIdx, contribIdx) + } + + // Landing runs from the first content line after the title and badges through the end of "Why + // decolint"; getting-started picks up at "Try it" and runs to just before the "---" separator + // that precedes "# Reference"; reference runs from just after "# Reference" to just before + // "Contributing", which belongs to the project rather than to decolint's own documentation. + contentStart := skipTitleAndBadges(lines) + + bodies := map[string]string{ + "_index": strings.Join(trimBlank(lines[contentStart:tryIdx]), "\n"), + "getting-started": strings.Join(trimHorizontalRule(trimBlank(lines[tryIdx:refIdx])), "\n"), + "reference": strings.Join(stripRulesTableMarkers(trimBlank(lines[refIdx+1:contribIdx])), "\n"), + } + + // The heading -> page map spans all three bodies, so a link anywhere among them can be resolved + // to the page it actually lives on, or correctly left alone when it's already on the right one. + slugPage := map[string]string{} + for name, body := range bodies { + for _, h := range scanHeadings(body) { + slugPage[h.Slug] = name + } + } + + frontMatter := map[string]string{ + "_index": "title: decolint\ndescription: " + yamlSingleQuoted(landingDescription), + "getting-started": "title: Getting started\ndescription: " + yamlSingleQuoted(gettingStartedSummary), + // Reference is long enough, and the flattest (mostly ## headings, few subsections), to be + // worth a table of contents; see docs/layouts/page.html. + "reference": "title: Reference\ndescription: " + yamlSingleQuoted(referenceSummary) + "\ntoc: true", + } + + pages := make(map[string]string, len(bodies)) + for name, body := range bodies { + rewritten := rewriteAnchors(body, name, slugPage) + pages[name] = "---\n" + frontMatter[name] + "\n---\n\n" + rewritten + "\n" + } + return pages, nil +} + +// findHeadingLine returns the index of the line in lines that is exactly the ATX heading want +// (e.g. "## Why decolint"), or -1 if there is none. +func findHeadingLine(lines []string, want string) int { + for i, l := range lines { + if l == want { + return i + } + } + return -1 +} + +// skipTitleAndBadges returns the index of the first line of body prose: past the "# " title and any +// immediately following badge lines ("[![..."). +func skipTitleAndBadges(lines []string) int { + i := 0 + for i < len(lines) && !strings.HasPrefix(lines[i], "# ") { + i++ + } + i++ // past the title line itself + for i < len(lines) && (strings.TrimSpace(lines[i]) == "" || strings.HasPrefix(strings.TrimSpace(lines[i]), "[![")) { + i++ + } + return i +} + +// trimBlank drops leading and trailing blank lines from lines. +func trimBlank(lines []string) []string { + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + end := len(lines) + for end > start && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + return lines[start:end] +} + +// stripRulesTableMarkers drops the rulesTableStart/rulesTableEnd lines: bookkeeping for +// updateReadmeRulesTable, meaningless once the table is on its own page rather than sitting in +// README.md. +func stripRulesTableMarkers(lines []string) []string { + out := make([]string, 0, len(lines)) + for _, l := range lines { + if l == rulesTableStart || l == rulesTableEnd { + continue + } + out = append(out, l) + } + return out +} + +// trimHorizontalRule drops a trailing "---" line (and the blank lines around it, already stripped +// by trimBlank) — the separator README.md draws between the guide and "# Reference", which has +// nothing to do with the guide's own last section. +func trimHorizontalRule(lines []string) []string { + if len(lines) > 0 && lines[len(lines)-1] == readmeHorizontalRule { + return trimBlank(lines[:len(lines)-1]) + } + return lines +} + +// anchorLink matches a Markdown link whose target is a same-document fragment, e.g. "(#config-file)". +var anchorLink = regexp.MustCompile(`\]\(#([a-z0-9-]+)\)`) + +// rewriteAnchors rewrites every "(#slug)" link in body that names a heading living on a different +// page than page, to "(.md#slug)", fence-aware so a "#" shown inside an example is never +// touched. A slug not found in slugPage (nothing in README.md's current content triggers this; see +// writeReadmePages) is left alone rather than guessed at. +func rewriteAnchors(body, page string, slugPage map[string]string) string { + lines := strings.Split(body, "\n") + fenced := false + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + fenced = !fenced + continue + } + if fenced { + continue + } + lines[i] = anchorLink.ReplaceAllStringFunc(line, func(m string) string { + slug := anchorLink.FindStringSubmatch(m)[1] + target, ok := slugPage[slug] + if !ok || target == page { + return m + } + return "](" + target + ".md#" + slug + ")" + }) + } + return strings.Join(lines, "\n") +} diff --git a/cmd/docgen/readme_test.go b/cmd/docgen/readme_test.go new file mode 100644 index 0000000..38d378c --- /dev/null +++ b/cmd/docgen/readme_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fixtureReadme is a minimal README.md with the section structure splitReadme requires: the +// headings it splits on, a cross-page anchor link in each direction, and the rules table markers, +// so a single fixture exercises the heading split, anchor rewriting and marker stripping together. +const fixtureReadme = `# example + +[![CI](https://example.invalid/badge.svg)](https://example.invalid) + +Intro paragraph. See [Config file](#config-file). + +## Why decolint + +- A landing page bullet. + +## Try it + +Getting started body. Self link: [Try it](#try-it). + +## Linting a Feature or Template + +Last guide section. + +--- + +# Reference + +## What decolint lints + +Reference body. + +## Config file + + +| ID | +| --- | +| ` + "`x`" + ` | + + +## Contributing + +Not part of the site. +` + +func TestSplitReadme(t *testing.T) { + t.Parallel() + + pages, err := splitReadme(fixtureReadme) + if err != nil { + t.Fatalf("splitReadme: %v", err) + } + for _, name := range []string{"_index", "getting-started", "reference"} { + if _, ok := pages[name]; !ok { + t.Errorf("splitReadme result has no %q page", name) + } + } + + landing := pages["_index"] + if !strings.Contains(landing, "title: decolint") { + t.Errorf("_index front matter missing title, got:\n%s", landing) + } + if !strings.Contains(landing, "Intro paragraph.") || !strings.Contains(landing, "landing page bullet") { + t.Errorf("_index body missing expected content, got:\n%s", landing) + } + if strings.Contains(landing, "## Try it") { + t.Errorf("_index leaked getting-started content, got:\n%s", landing) + } + // "Config file" lives on reference, so the landing page's link to it must be rewritten. + if !strings.Contains(landing, "(reference.md#config-file)") { + t.Errorf("_index anchor to #config-file was not rewritten to reference.md, got:\n%s", landing) + } + + gs := pages["getting-started"] + if !strings.Contains(gs, "title: Getting started") { + t.Errorf("getting-started front matter missing title, got:\n%s", gs) + } + if strings.Contains(gs, "Reference body") || strings.Contains(gs, "Not part of the site") { + t.Errorf("getting-started leaked reference/contributing content, got:\n%s", gs) + } + // "Try it" is on the same page, so this link must stay untouched. + if !strings.Contains(gs, "[Try it](#try-it)") { + t.Errorf("getting-started same-page anchor was rewritten, got:\n%s", gs) + } + gsBody := strings.SplitN(gs, "\n---\n\n", 2)[1] // past the front matter fence + if strings.Contains(gsBody, "---") { + t.Errorf("getting-started retained the horizontal rule before # Reference, got body:\n%s", gsBody) + } + + ref := pages["reference"] + if !strings.Contains(ref, "title: Reference") || !strings.Contains(ref, "toc: true") { + t.Errorf("reference front matter missing title/toc, got:\n%s", ref) + } + if strings.Contains(ref, "Not part of the site") { + t.Errorf("reference leaked Contributing content, got:\n%s", ref) + } + if strings.Contains(ref, rulesTableStart) || strings.Contains(ref, rulesTableEnd) { + t.Errorf("reference retained the rules table markers, got:\n%s", ref) + } + if !strings.Contains(ref, "| `x` |") { + t.Errorf("reference lost the table content between the markers, got:\n%s", ref) + } +} + +func TestSplitReadme_MissingHeading(t *testing.T) { + t.Parallel() + + _, err := splitReadme("# example\n\n## Try it\n\nno other headings\n") + if err == nil { + t.Fatal("splitReadme with missing headings: got nil error, want one") + } +} + +func TestWriteReadmePages(t *testing.T) { + t.Parallel() + + readmePath := filepath.Join(t.TempDir(), "README.md") + if err := os.WriteFile(readmePath, []byte(fixtureReadme), 0o644); err != nil { + t.Fatalf("write fixture README: %v", err) + } + dir := t.TempDir() + + if err := writeReadmePages(readmePath, dir); err != nil { + t.Fatalf("writeReadmePages: %v", err) + } + for _, name := range []string{"_index.md", "getting-started.md", "reference.md"} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("writeReadmePages did not create %s: %v", name, err) + } + } +} diff --git a/cmd/docgen/rules.go b/cmd/docgen/rules.go new file mode 100644 index 0000000..d13a292 --- /dev/null +++ b/cmd/docgen/rules.go @@ -0,0 +1,174 @@ +package main + +import ( + "cmp" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/bare-devcontainer/decolint/linter" + "github.com/bare-devcontainer/decolint/rules" +) + +// writeRulePages renders one Markdown page per built-in rule into dir/rules, named after the rule's +// ID. It is the site's only source for the rule reference: nothing under docs/content/rules other +// than _index.md is committed, so a rule's documentation cannot drift from rules/*.go. +func writeRulePages(dir string) error { + rulesDir := filepath.Join(dir, "rules") + if err := os.MkdirAll(rulesDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", rulesDir, err) + } + for _, reg := range rules.Builtin() { + page := renderRulePage(reg.Rule) + path := filepath.Join(rulesDir, reg.Rule.ID+".md") + if err := os.WriteFile(path, []byte(page), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} + +// renderRulePage renders one rule's documentation page: front matter matching what +// docs/layouts/page.html reads, the rationale, a Bad and a Good example, and its references. +func renderRulePage(r *linter.Rule) string { + var b strings.Builder + + fmt.Fprintf(&b, "---\n") + fmt.Fprintf(&b, "title: %s\n", r.ID) + fmt.Fprintf(&b, "category: %s\n", r.Category) + fmt.Fprintf(&b, "platforms: [%s]\n", strings.Join(platformNames(r.Platforms), ", ")) + fmt.Fprintf(&b, "file_types: [%s]\n", strings.Join(fileTypeNames(r.FileTypes), ", ")) + fmt.Fprintf(&b, "description: %s\n", yamlSingleQuoted(r.Description)) + fmt.Fprintf(&b, "---\n\n") + + fmt.Fprintf(&b, "## Why\n\n%s\n\n", r.LongDescription) + + fmt.Fprintf(&b, "## Bad\n\n%s\n", strings.TrimRight(renderSnippet(r.Example.Bad), "\n")) + fmt.Fprintf(&b, "\n## Good\n\n%s\n", strings.TrimRight(renderSnippet(r.Example.Good), "\n")) + if r.Example.Note != "" { + fmt.Fprintf(&b, "\n%s\n", r.Example.Note) + } + + fmt.Fprintf(&b, "\n## References\n\n") + for _, ref := range r.References { + fmt.Fprintf(&b, "- <%s>\n", ref) + } + + return b.String() +} + +// renderSnippet renders a [linter.Snippet] as one fenced code block per file, each preceded by a +// "### `path`" heading when the snippet has more than one file, or when a non-default Mode is the +// only thing distinguishing the file from its counterpart in the other half of the example (e.g. an +// install.sh whose content is identical in Bad and Good). +func renderSnippet(s linter.Snippet) string { + var b strings.Builder + for _, f := range s.Files { + switch { + case f.Mode != 0: + fmt.Fprintf(&b, "### `%s` (mode %04o)\n\n", f.Path, f.Mode) + case len(s.Files) > 1: + fmt.Fprintf(&b, "### `%s`\n\n", f.Path) + } + fmt.Fprintf(&b, "```%s\n%s```\n\n", codeLang(f.Path), f.Content) + } + return b.String() +} + +// codeLang returns the fenced-block language for a file named path, guessed from its extension. +func codeLang(path string) string { + switch { + case strings.HasSuffix(path, ".json"): + // The examples use // comments for context, so they need JSONC's lexer, not plain JSON's. + return "jsonc" + case strings.HasSuffix(path, ".sh"): + return "bash" + default: + return "text" + } +} + +func platformNames(platforms []linter.Platform) []string { + names := make([]string, len(platforms)) + for i, p := range platforms { + names[i] = p.String() + } + return names +} + +func fileTypeNames(fileTypes []linter.FileType) []string { + names := make([]string, len(fileTypes)) + for i, ft := range fileTypes { + names[i] = string(ft) + } + return names +} + +// yamlSingleQuoted renders s as a single-quoted YAML scalar: the one quoting style that needs no +// escaping for the double quotes a rule's Description is full of ("image", "build", ...); a literal +// single quote doubles, per YAML's own rule for this style. +func yamlSingleQuoted(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// rulesTableMarkers delimit the generated rules table in README.md, so updateReadmeRulesTable can +// find and replace exactly that table and nothing else. +const ( + rulesTableStart = "" + rulesTableEnd = "" +) + +// updateReadmeRulesTable rewrites the table between the rulesTableStart/rulesTableEnd markers in +// the README at path to the current built-in rules, sorted by category (in the same order the +// category list above the table uses) then ID. It is an error if the markers are missing. +func updateReadmeRulesTable(path string) error { + src, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + start := strings.Index(string(src), rulesTableStart) + end := strings.Index(string(src), rulesTableEnd) + if start < 0 || end < 0 || end < start { + return fmt.Errorf("%s: rules table markers %q/%q not found", path, rulesTableStart, rulesTableEnd) + } + end += len(rulesTableEnd) + + table := rulesTableStart + "\n" + renderRulesTable() + rulesTableEnd + out := string(src[:start]) + table + string(src[end:]) + if out == string(src) { + return nil + } + return os.WriteFile(path, []byte(out), 0o644) +} + +// renderRulesTable renders every built-in rule as a Markdown table row, each ID linking to its +// documentation page, sorted by category then ID. +func renderRulesTable() string { + regs := slices.Clone(rules.Builtin()) + slices.SortFunc(regs, func(a, b rules.Registration) int { + if c := cmp.Compare(a.Rule.Category, b.Rule.Category); c != 0 { + return c + } + return cmp.Compare(a.Rule.ID, b.Rule.ID) + }) + + var b strings.Builder + b.WriteString("| ID | Category | Platform | Description |\n") + b.WriteString("| --- | --- | --- | --- |\n") + for _, reg := range regs { + r := reg.Rule + platform := "(all)" + if names := platformNames(r.Platforms); len(names) > 0 { + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = "`" + n + "`" + } + platform = strings.Join(quoted, ", ") + } + fmt.Fprintf(&b, "| [`%s`](%s) | `%s` | %s | %s |\n", r.ID, rules.DocsURL(r.ID), r.Category, platform, r.Description) + } + return b.String() +} diff --git a/cmd/docgen/rules_test.go b/cmd/docgen/rules_test.go new file mode 100644 index 0000000..a2199e1 --- /dev/null +++ b/cmd/docgen/rules_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bare-devcontainer/decolint/linter" + "github.com/bare-devcontainer/decolint/rules" +) + +func TestYamlSingleQuoted(t *testing.T) { + t.Parallel() + + tests := []struct{ in, want string }{ + {`disallow "privileged"`, `'disallow "privileged"'`}, + {`it's fine`, `'it''s fine'`}, + {"plain", "'plain'"}, + } + for _, tt := range tests { + if got := yamlSingleQuoted(tt.in); got != tt.want { + t.Errorf("yamlSingleQuoted(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestCodeLang(t *testing.T) { + t.Parallel() + + tests := []struct{ path, want string }{ + {"devcontainer.json", "jsonc"}, + {"devcontainer-feature.json", "jsonc"}, + {"install.sh", "bash"}, + {"README.md", "text"}, + } + for _, tt := range tests { + if got := codeLang(tt.path); got != tt.want { + t.Errorf("codeLang(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +func TestRenderRulePage(t *testing.T) { + t.Parallel() + + r := &linter.Rule{ + ID: "my-rule", + Description: `disallow "foo"`, + LongDescription: "Why it matters.", + References: []string{"https://example.invalid/a", "https://example.invalid/b"}, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{{Path: "devcontainer.json", Content: "{\n \"privileged\": true\n}\n"}}, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{{Path: "devcontainer.json", Content: "{}\n"}}, + }, + Note: "A closing note.", + }, + } + + page := renderRulePage(r) + + for _, want := range []string{ + "title: my-rule", + "category: security", + "platforms: [codespaces]", + "file_types: [devcontainer]", + `description: 'disallow "foo"'`, + "## Why\n\nWhy it matters.", + "## Bad", + `"privileged": true`, + "## Good", + "A closing note.", + "## References", + "- ", + "- ", + } { + if !strings.Contains(page, want) { + t.Errorf("renderRulePage() missing %q, got:\n%s", want, page) + } + } +} + +func TestRenderRulePage_MultiFileExample(t *testing.T) { + t.Parallel() + + r := &linter.Rule{ + ID: "multi-file-rule", + Description: "d", + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Template}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer-template.json", Content: "{}\n"}, + {Path: ".devcontainer/devcontainer.json", Content: "{}\n"}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer-template.json", Content: "{}\n"}, + {Path: ".devcontainer/devcontainer.json", Content: "{}\n"}, + }, + }, + }, + } + + page := renderRulePage(r) + for _, want := range []string{ + "### `devcontainer-template.json`", + "### `.devcontainer/devcontainer.json`", + } { + if !strings.Contains(page, want) { + t.Errorf("renderRulePage() missing %q for a multi-file example, got:\n%s", want, page) + } + } +} + +func TestRenderRulePage_ModeCaption(t *testing.T) { + t.Parallel() + + r := &linter.Rule{ + ID: "mode-rule", + Description: "d", + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{{Path: "install.sh", Content: "#!/bin/sh\n", Mode: 0o644}}, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{{Path: "install.sh", Content: "#!/bin/sh\n", Mode: 0o755}}, + }, + }, + } + + page := renderRulePage(r) + if !strings.Contains(page, "(mode 0644)") { + t.Errorf("renderRulePage() missing the Bad file's mode caption, got:\n%s", page) + } + if !strings.Contains(page, "(mode 0755)") { + t.Errorf("renderRulePage() missing the Good file's mode caption, got:\n%s", page) + } +} + +func TestWriteRulePages(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if err := writeRulePages(dir); err != nil { + t.Fatalf("writeRulePages: %v", err) + } + + builtin := rules.Builtin() + entries, err := os.ReadDir(filepath.Join(dir, "rules")) + if err != nil { + t.Fatalf("read rules dir: %v", err) + } + if len(entries) != len(builtin) { + t.Errorf("writeRulePages wrote %d file(s), want %d (one per built-in rule)", len(entries), len(builtin)) + } + + path := filepath.Join(dir, "rules", "no-image-latest.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !strings.Contains(string(data), "title: no-image-latest") { + t.Errorf("%s missing expected title, got:\n%s", path, data) + } +} + +func TestRenderRulesTable(t *testing.T) { + t.Parallel() + + table := renderRulesTable() + if !strings.HasPrefix(table, "| ID | Category | Platform | Description |\n") { + t.Fatalf("renderRulesTable() does not start with the header row, got:\n%s", table) + } + + rows := strings.Split(strings.TrimRight(table, "\n"), "\n") + if want := len(rules.Builtin()) + 2; len(rows) != want { // +2 for the header and separator rows + t.Errorf("renderRulesTable() produced %d row(s), want %d", len(rows), want) + } + + // Sorted by category (Correctness < Security < ... per the linter.Category iota order), then ID: + // the first data row must be a correctness rule, and the table must link to rules.DocsURL. + if !strings.Contains(rows[2], "`correctness`") { + t.Errorf("first data row is not a correctness rule, got: %s", rows[2]) + } + if !strings.Contains(table, rules.DocsURL("no-image-latest")) { + t.Errorf("renderRulesTable() does not link to rules.DocsURL, got:\n%s", table) + } +} + +func TestUpdateReadmeRulesTable(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "README.md") + original := "prose\n\n" + rulesTableStart + "\nstale\n" + rulesTableEnd + "\n\nmore prose\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := updateReadmeRulesTable(path); err != nil { + t.Fatalf("updateReadmeRulesTable: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if strings.Contains(string(got), "stale") { + t.Errorf("updateReadmeRulesTable did not replace the stale table, got:\n%s", got) + } + if !strings.HasPrefix(string(got), "prose\n\n"+rulesTableStart) || !strings.HasSuffix(string(got), "\n\nmore prose\n") { + t.Errorf("updateReadmeRulesTable disturbed content outside the markers, got:\n%s", got) + } + + // Running it again on its own output must be a no-op (the generator has to be idempotent, since + // CI fails the build if it finds anything left to regenerate). + if err := updateReadmeRulesTable(path); err != nil { + t.Fatalf("updateReadmeRulesTable (second run): %v", err) + } + got2, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(got2) != string(got) { + t.Errorf("updateReadmeRulesTable is not idempotent:\nfirst:\n%s\nsecond:\n%s", got, got2) + } +} + +func TestUpdateReadmeRulesTable_MissingMarkers(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "README.md") + if err := os.WriteFile(path, []byte("no markers here\n"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + if err := updateReadmeRulesTable(path); err == nil { + t.Fatal("updateReadmeRulesTable with no markers: got nil error, want one") + } +} diff --git a/cmd/docgen/slug.go b/cmd/docgen/slug.go new file mode 100644 index 0000000..40c9de8 --- /dev/null +++ b/cmd/docgen/slug.go @@ -0,0 +1,69 @@ +package main + +import ( + "regexp" + "strings" +) + +// slugify computes a Markdown heading's anchor slug the way GitHub (and Hugo's default goldmark +// auto-heading-id extension) does: lowercase, drop everything but letters, digits, spaces and +// hyphens, then turn runs of spaces/hyphens into one hyphen. Verified against every heading in +// README.md to match the anchors already written by hand there. +func slugify(heading string) string { + var b strings.Builder + for _, r := range strings.ToLower(heading) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == ' ', r == '-': + b.WriteRune(r) + } + } + return strings.Trim(collapseHyphens.ReplaceAllString(b.String(), "-"), "-") +} + +var collapseHyphens = regexp.MustCompile(`[\s-]+`) + +// heading is one ATX heading found by scanHeadings: its level (1 for "#", 2 for "##", ...), text, +// and computed slug. +type heading struct { + Level int + Text string + Slug string +} + +// scanHeadings returns every ATX heading ("# ", "## ", ...) in body, skipping fenced code blocks so +// a "#" inside an example is never mistaken for one. README.md has none today, but a generator that +// assumed so would fail silently the day it does. +func scanHeadings(body string) []heading { + var out []heading + fenced := false + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + fenced = !fenced + continue + } + if fenced { + continue + } + level, text, ok := atxHeading(line) + if !ok { + continue + } + out = append(out, heading{Level: level, Text: text, Slug: slugify(text)}) + } + return out +} + +// atxHeading parses line as an ATX heading ("# Title" through "###### Title"), returning its level +// and text with any trailing "#"s trimmed. +func atxHeading(line string) (level int, text string, ok bool) { + for level = 0; level < len(line) && level < 6 && line[level] == '#'; level++ { + } + if level == 0 || level >= len(line) || line[level] != ' ' { + return 0, "", false + } + text = strings.TrimSpace(strings.TrimRight(line[level+1:], "#")) + if text == "" { + return 0, "", false + } + return level, text, true +} diff --git a/cmd/docgen/slug_test.go b/cmd/docgen/slug_test.go new file mode 100644 index 0000000..d3fd200 --- /dev/null +++ b/cmd/docgen/slug_test.go @@ -0,0 +1,64 @@ +package main + +import "testing" + +func TestSlugify(t *testing.T) { + t.Parallel() + + tests := []struct { + heading string + want string + }{ + {"Why decolint", "why-decolint"}, + {"1. Run it", "1-run-it"}, + {"4. Lint what actually runs", "4-lint-what-actually-runs"}, + {"Prebuilt binary (recommended)", "prebuilt-binary-recommended"}, + {"Config file", "config-file"}, + {" Extra spaces ", "extra-spaces"}, + } + for _, tt := range tests { + if got := slugify(tt.heading); got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.heading, got, tt.want) + } + } +} + +func TestScanHeadings_SkipsFencedCode(t *testing.T) { + t.Parallel() + + body := "## Real heading\n\n```console\n# not a heading\n## also not one\n```\n\n## Another\n" + got := scanHeadings(body) + if len(got) != 2 { + t.Fatalf("scanHeadings found %d heading(s), want 2: %+v", len(got), got) + } + if got[0].Text != "Real heading" || got[1].Text != "Another" { + t.Errorf("scanHeadings = %+v", got) + } +} + +func TestRewriteAnchors(t *testing.T) { + t.Parallel() + + slugPage := map[string]string{ + "local": "getting-started", + "remote": "reference", + } + body := "See [here](#local) and [there](#remote), plus [unknown](#nowhere)." + got := rewriteAnchors(body, "getting-started", slugPage) + want := "See [here](#local) and [there](reference.md#remote), plus [unknown](#nowhere)." + if got != want { + t.Errorf("rewriteAnchors() = %q, want %q", got, want) + } +} + +func TestRewriteAnchors_SkipsFencedCode(t *testing.T) { + t.Parallel() + + slugPage := map[string]string{"remote": "reference"} + body := "text [a](#remote)\n\n```\n[b](#remote)\n```\n" + got := rewriteAnchors(body, "getting-started", slugPage) + want := "text [a](reference.md#remote)\n\n```\n[b](#remote)\n```\n" + if got != want { + t.Errorf("rewriteAnchors() = %q, want %q", got, want) + } +} diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 3b7f8b9..1eafc6a 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -241,3 +241,24 @@ h3 { padding: 0 0 1rem; } } + +/* Table of contents (long pages, e.g. Reference) */ + +.toc { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + margin: 0 0 2rem; + padding: 0.9rem 1.2rem; +} + +.toc ul { + list-style: none; + margin: 0; + padding-left: 1rem; +} + +.toc > ul { + padding-left: 0; +} diff --git a/docs/content/_index.md b/docs/content/_index.md deleted file mode 100644 index 7d65adc..0000000 --- a/docs/content/_index.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: decolint ---- - -decolint is a linter for [Dev Container](https://containers.dev/) configuration -files: `devcontainer.json`, `devcontainer-feature.json`, and -`devcontainer-template.json`. It reports mistakes, container privileges, and -unpinned versions that the Dev Container tooling itself accepts without a word. - -Take a `devcontainer.json` that opens cleanly in VS Code and builds without -complaint: - -```jsonc -// .devcontainer/devcontainer.json -{ - "name": "api", - "image": "mcr.microsoft.com/devcontainers/go:latest", - "features": { - "ghcr.io/devcontainers/features/docker-in-docker": {} - }, - "runArgs": ["--privileged"], - "mounts": [ - "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" - ], - "forwardPorts": ["db:5432"] -} -``` - -```console -$ decolint . -Config: .decolint.jsonc -Linted 1 file: - .devcontainer/devcontainer.json (devcontainer) - -.devcontainer/devcontainer.json:1:1: error: "ALL" is not set via "runArgs", leaving the container with its default Linux capabilities (require-cap-drop-all) -.devcontainer/devcontainer.json:1:1: error: "no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges (require-no-new-privileges) -.devcontainer/devcontainer.json:1:1: error: neither "remoteUser" nor "containerUser" is set, so the container defaults to running as root (require-non-root) -.devcontainer/devcontainer.json:3:12: error: image "mcr.microsoft.com/devcontainers/go:latest" uses the "latest" tag; pin a specific version (no-image-latest) -.devcontainer/devcontainer.json:3:12: error: image "mcr.microsoft.com/devcontainers/go:latest" is not pinned by digest; add an "@sha256:..." digest (pin-image-digest) -.devcontainer/devcontainer.json:5:5: error: feature "ghcr.io/devcontainers/features/docker-in-docker" has no explicit version; pin a specific version (pin-feature-version) -.devcontainer/devcontainer.json:7:15: error: "runArgs" contains "--privileged", disabling the container's isolation from the host (no-privileged-container) -.devcontainer/devcontainer.json:9:5: error: "mounts" entry bind-mounts the Docker socket, which grants the container root-equivalent control over the host (no-docker-socket-mount) -.devcontainer/devcontainer.json:11:20: error: "forwardPorts" entry "db:5432" uses "host:port" format; Codespaces only supports a bare port number (no-host-port-format) -Found 9 errors and 0 warnings. -``` - -That run has every category and both target platforms enabled; the defaults are -quieter. See [Getting started](getting-started.md). - -## Why decolint - -- **A `devcontainer.json` is container runtime configuration, and nobody reviews - it that way.** `--privileged` and a bind-mounted Docker socket read like - ordinary setup lines, and they ship to every teammate and every CI run that - rebuilds the container. -- **It lints what actually runs, not just what you wrote.** The Features you - reference and the base image you name contribute configuration of their own. - With [`-merge`](getting-started.md#4-lint-what-actually-runs), decolint - resolves them the way the real tooling does and lints the result. -- **A silently ignored property is a mistake decolint names.** GitHub Codespaces - drops `bind` mounts and rejects `host:port` entries without reporting - anything; the container just comes up wrong. -- **It goes where your other linters already are.** Findings carry a line and - column, and come out as text, JSON, GitHub Actions annotations, or SARIF. -- **Every finding is explained.** Each rule has a [page here](rules/) covering - why it exists and the configuration it accepts and rejects, and the SARIF - output links to it, so a code scanning alert carries the reasoning with it. - -## Next steps - -- [Getting started](getting-started.md) — install decolint, choose what it - reports, and wire it into CI. -- [Rules](rules/) — every rule, with the reasoning behind it and examples of the - configuration it accepts and rejects. -- [README](https://github.com/bare-devcontainer/decolint#reference) — the full - reference for flags, the config file, output formats and merging. diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md deleted file mode 100644 index e4317e9..0000000 --- a/docs/content/getting-started.md +++ /dev/null @@ -1,278 +0,0 @@ ---- -title: Getting started -description: >- - Install decolint, choose what it reports, and wire it into CI. ---- - -This page takes you from nothing installed to decolint running in CI. Every -flag and config member it mentions is documented in full in the -[README's reference](https://github.com/bare-devcontainer/decolint#reference). - -## Try it - -Run it against your own repository, without installing anything: - -```console -docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -``` - -By default only the `correctness` checks run — configuration that is invalid or -does not behave as written. The security, pinning, and style checks are opt-in, -and one config file away. - -## Install - -A prebuilt binary is the quickest way to start. Download one from the -[releases page](https://github.com/bare-devcontainer/decolint/releases); the -artifacts are signed and carry build provenance. - -The container image is published for `linux/amd64` and `linux/arm64`, tagged -`latest`, ``, `.`, and `..`: - -```console -docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint [directory ...] -``` - -Or build it from source: - -```console -GOEXPERIMENT=jsonv2 go install github.com/bare-devcontainer/decolint/cmd/decolint@latest -``` - -`GOEXPERIMENT=jsonv2` is required because decolint uses the still experimental -`encoding/json/v2` package. - -## 1. Run it - -With no arguments the current directory is linted; name directories to lint -those instead. Whatever you point it at is detected as a dev container -definition, a Feature, or a Template. - -```console -$ decolint -Config: none (defaults; run "decolint -init" to create .decolint.jsonc) -Linted 1 file: - .devcontainer/devcontainer.json (devcontainer) - -Found 0 errors and 0 warnings. -``` - -The header says why a run reported nothing: no config file, so only the -defaults are in effect. It also lists what was covered, which is the other -thing worth checking. - -## 2. Turn on the checks you want - -The defaults are `correctness` alone. Everything else — the container -privileges, the unpinned versions, the legacy properties — is off until you ask -for it. Write a `.decolint.jsonc` in your repository root: - -```jsonc -// .decolint.jsonc -{ - "categories": { - "correctness": "error", - "security": "error", - "reproducibility": "error", - "style": "error" - } -} -``` - -That is the strictest setting. Start narrower if a whole category is more than -you want today, and set individual rules where a category is close but not -right: - -```jsonc -{ - "categories": { - "security": "error" - }, - "rules": { - "no-image-latest": "error", - "require-non-root": "off" - } -} -``` - -Every severity is `error`, `warn`, or `off`, and a `rules` entry beats its -category. Run `decolint -rules` to see every rule with the severity your config -gives it, or `decolint -init` to generate a config that lists all of them -explicitly. - -Each category groups rules by the kind of problem they report: - -| Category | Default | Reports | -| --- | --- | --- | -| `correctness` | `error` | configuration that is invalid or does not behave as written | -| `security` | `off` | container runtime privileges and hardening | -| `reproducibility` | `off` | unpinned versions or digests that let the environment drift | -| `style` | `off` | discouraged or legacy configuration that still works | - -The [rule reference](rules/) lists what each one checks and why. - -## 3. Name your platform - -Some rules only make sense on a particular platform — Codespaces ignoring -`bind` mounts, VS Code pinning extension versions — and those stay off until -you say which platforms you target: - -```jsonc -{ - "platforms": ["vscode", "codespaces"] -} -``` - -## 4. Lint what actually runs - -Your `devcontainer.json` is not the whole configuration. Features and the base -image contribute their own, and the tooling merges it all together before the -container starts. Take a file that is careful about all of it — pinned by -digest, capabilities dropped, no new privileges: - -```jsonc -// .devcontainer/devcontainer.json -{ - "name": "api", - "image": "mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3", - "runArgs": ["--cap-drop=ALL"], - "securityOpt": ["no-new-privileges"] -} -``` - -Linted as written it reports one problem. Merging replaces it with four: - -```console -$ decolint . -Config: .decolint.jsonc -Linted 1 file: - .devcontainer/devcontainer.json (devcontainer) - -.devcontainer/devcontainer.json:1:1: error: neither "remoteUser" nor "containerUser" is set, so the container defaults to running as root (require-non-root) -Found 1 error and 0 warnings. - -$ decolint -merge . -Downloading image metadata(mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3) -Config: .decolint.jsonc -Linted 1 file: - .devcontainer/devcontainer.json (devcontainer) - -.devcontainer/devcontainer.json:3:3: error: "securityOpt" overrides the default seccomp profile (no-seccomp-override) -.devcontainer/devcontainer.json:3:3: error: "securityOpt" contains "seccomp=unconfined", disabling the container's syscall filtering (no-seccomp-unconfined) -.devcontainer/devcontainer.json:3:3: error: extension "golang.Go" has no explicit version; pin a specific version (pin-extension-version) -.devcontainer/devcontainer.json:3:3: error: extension "dbaeumer.vscode-eslint" has no explicit version; pin a specific version (pin-extension-version) -Found 4 errors and 0 warnings. -``` - -The one reported without merging was wrong: the base image sets a non-root -user, so `require-non-root` never applied. The four reported with merging are -in nothing you wrote — the image disables seccomp and installs two unpinned VS -Code extensions. Findings that come from merged content are reported at the -property that pulled it in, here the `image` line. - -Turn it on for good with `"merge": true`, or pass `-merge` per run. It fetches -every referenced Feature and resolves the base image, so it needs network -access and belongs in CI rather than in a pre-commit hook. - -## 5. Fix it, or say why not - -When a finding is deliberate, suppress it in the configuration file itself so -the reason lives next to the line: - -```jsonc -{ - // decolint-ignore-next-line no-image-latest - "image": "ubuntu:latest" -} -``` - -`decolint-ignore-line` covers the line it is on, `decolint-ignore-next-line` -the line after it, and `decolint-ignore-file` the whole file. Naming rule IDs -after the directive limits it to those rules; naming none suppresses -everything. - -## Add it to CI - -decolint exits `1` when it reports an `error`, which is all a CI job needs to -fail. Add `-format=github` and the findings also appear as annotations on the -pull request diff: - -```yaml -name: devcontainer -on: pull_request -permissions: {} - -jobs: - decolint: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=github . -``` - -Only findings become annotations; the files that were linted go to a collapsed -group in the run log, so a clean file does not annotate the diff. Warnings do -not fail the build on their own; add `-deny-warnings` to make them count. - -To have findings tracked as alerts in the repository's Security tab instead, -emit SARIF and upload it: - -```yaml - decolint-sarif: - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=sarif . > decolint.sarif - continue-on-error: true # decolint exits 1 on findings; the upload must still run - - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: decolint.sarif -``` - -A pull request from a fork gets a read-only token whatever the job asks for, so -the upload cannot succeed there; skip the job on fork pull requests, or run it -only on pushes to your default branch. - -Run decolint from the repository root, so the reported paths resolve to files -in the repository. Each alert links back to the rule's page here, so whoever -picks it up gets the reasoning without leaving the alert. - -## Linting a Feature or Template - -If you publish a [Feature](https://containers.dev/implementors/features/) or a -[Template](https://containers.dev/implementors/templates/), the mistakes that -cost the most are the ones your consumers find after you have shipped: an `id` -that does not match its directory, a version that is not semver, an `install.sh` -that was committed without its executable bit. Point decolint at the directory — -these are `correctness` rules, so they need no configuration: - -```console -$ decolint src/go-tools -Config: none (defaults; run "decolint -init" to create .decolint.jsonc) -Linted 1 file: - src/go-tools/devcontainer-feature.json (feature) - -src/go-tools/devcontainer-feature.json:1:1: error: "install.sh" is not executable (mode 0644); run "chmod +x install.sh" (feature-install-script-not-executable) -src/go-tools/devcontainer-feature.json:2:9: error: id "gotools" does not match containing directory "go-tools" (id-dir-mismatch) -src/go-tools/devcontainer-feature.json:3:14: error: version "1.0" is not a valid semantic version (see https://semver.org/) (invalid-semver) -Found 3 errors and 0 warnings. -``` - -A Template directory is linted the same way, and the dev container -configuration the Template ships is linted along with it, including its -`${templateOption:...}` references. - -## Next steps - -- [Rules](rules/) — what each rule checks, why, and what it accepts. -- [README](https://github.com/bare-devcontainer/decolint#reference) — the full - reference for flags, the config file, output formats, color and merging. diff --git a/docs/content/rules/conflicting-container-def.md b/docs/content/rules/conflicting-container-def.md deleted file mode 100644 index cb58f41..0000000 --- a/docs/content/rules/conflicting-container-def.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: conflicting-container-def -category: correctness -platforms: [] -file_types: [devcontainer] -description: >- - disallow a devcontainer.json that defines more than one of "image", - "build", or "dockerComposeFile" ---- - -## Why - -The specification defines three mutually exclusive ways to create the container: from an image, from a -Dockerfile, or from a Docker Compose project. Which one wins when several are set is unspecified, so the -container that gets built depends on the tool rather than on the configuration. Keep the variant the -project actually uses and remove the others. - -## Bad - -```jsonc -{ - "name": "my project", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "build": { - "dockerfile": "Dockerfile" - } -} -``` - -## Good - -```jsonc -{ - "name": "my project", - "build": { - "dockerfile": "Dockerfile" - } -} -``` - -## References - -- -- diff --git a/docs/content/rules/feature-install-script-not-executable.md b/docs/content/rules/feature-install-script-not-executable.md deleted file mode 100644 index 51b3458..0000000 --- a/docs/content/rules/feature-install-script-not-executable.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: feature-install-script-not-executable -category: correctness -platforms: [] -file_types: [feature] -description: >- - disallow a Feature's `install.sh` that lacks executable permission bits -example_verify: false ---- - -## Why - -The specification has the installing tool invoke "install.sh" directly rather than through a shell, so -that the script's own shebang selects the interpreter. That requires the execute bit: without it the -Feature fails to install when a container is built. Run "chmod +x install.sh" and commit the mode change. - -## Bad - -```console -$ ls -l install.sh --rw-r--r-- 1 user user 214 Jan 1 00:00 install.sh -``` - -## Good - -```console -$ chmod +x install.sh -$ ls -l install.sh --rwxr-xr-x 1 user user 214 Jan 1 00:00 install.sh -``` - -Git records the executable bit, so committing the mode change is what makes -the fix stick. On Windows, where the filesystem has no executable bit, set it -in the index directly: `git update-index --chmod=+x install.sh`. - -## References - -- diff --git a/docs/content/rules/id-dir-mismatch.md b/docs/content/rules/id-dir-mismatch.md deleted file mode 100644 index 4000dfc..0000000 --- a/docs/content/rules/id-dir-mismatch.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: id-dir-mismatch -category: correctness -platforms: [] -file_types: [feature, template] -description: >- - disallow a Feature's or Template's "id" that does not match the name of - its containing directory -example_dir: node ---- - -## Why - -Both specifications require the "id" to match the name of the directory holding the metadata file, since -that directory name is what packaging and distribution address the artifact by. When the two disagree the -published reference does not resolve to what the directory contains; rename the directory or the "id" so -they agree. - -## Bad - -```jsonc -// src/node/devcontainer-feature.json -{ - "id": "nodejs", - "version": "1.0.0", - "name": "Node.js" -} -``` - -## Good - -```jsonc -// src/node/devcontainer-feature.json -{ - "id": "node", - "version": "1.0.0", - "name": "Node.js" -} -``` - -## References - -- -- diff --git a/docs/content/rules/invalid-semver.md b/docs/content/rules/invalid-semver.md deleted file mode 100644 index 79febb7..0000000 --- a/docs/content/rules/invalid-semver.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: invalid-semver -category: correctness -platforms: [] -file_types: [feature, template] -description: >- - disallow a Feature's or Template's "version" that is not a valid semantic - version ---- - -## Why - -Publishing a Feature or Template pushes it under tags derived from the "version" components: the full -version, "major.minor", and "major", so consumers can pin as loosely or as tightly as they want. A value -that is not valid semver has no such components, leaving nothing to derive those tags from. - -## Bad - -```jsonc -{ - "id": "node", - "version": "1.0", - "name": "Node.js" -} -``` - -## Good - -```jsonc -{ - "id": "node", - "version": "1.0.0", - "name": "Node.js" -} -``` - -## References - -- -- diff --git a/docs/content/rules/missing-build-dockerfile.md b/docs/content/rules/missing-build-dockerfile.md deleted file mode 100644 index 90900aa..0000000 --- a/docs/content/rules/missing-build-dockerfile.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: missing-build-dockerfile -category: correctness -platforms: [] -file_types: [devcontainer] -description: >- - disallow a devcontainer.json "build" object that is missing "dockerfile" ---- - -## Why - -"build.dockerfile" is the only required member of "build": it locates, relative to the devcontainer.json, -the Dockerfile the image is built from. The other members ("context", "args", "target", ...) only shape a -build that "dockerfile" defines, so without it there is nothing to build. - -## Bad - -```jsonc -{ - "name": "my project", - "build": { - "context": ".." - } -} -``` - -## Good - -```jsonc -{ - "name": "my project", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - } -} -``` - -## References - -- -- diff --git a/docs/content/rules/missing-compose-service.md b/docs/content/rules/missing-compose-service.md deleted file mode 100644 index d8219b6..0000000 --- a/docs/content/rules/missing-compose-service.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: missing-compose-service -category: correctness -platforms: [] -file_types: [devcontainer] -description: >- - disallow a devcontainer.json that sets "dockerComposeFile" without - "service" ---- - -## Why - -A Compose project usually defines several services, so naming the Compose file does not say which -container the tooling should attach to. The specification requires "service" to name that main container: -it is the one lifecycle scripts run in and the one editors connect to. - -## Bad - -```jsonc -{ - "name": "my project", - "dockerComposeFile": "docker-compose.yml", - "workspaceFolder": "/workspace" -} -``` - -## Good - -```jsonc -{ - "name": "my project", - "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspace" -} -``` - -## References - -- -- diff --git a/docs/content/rules/missing-container-def.md b/docs/content/rules/missing-container-def.md deleted file mode 100644 index 77d956a..0000000 --- a/docs/content/rules/missing-container-def.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: missing-container-def -category: correctness -platforms: [] -file_types: [devcontainer] -description: >- - disallow a devcontainer.json that defines none of "image", "build", or - "dockerComposeFile" ---- - -## Why - -Every dev container is created from exactly one of "image", "build", or "dockerComposeFile", and each of -the three is required in its own scenario. A configuration that sets none of them describes no container -at all, so no tool can create one from it. - -## Bad - -```jsonc -{ - "name": "my project", - "forwardPorts": [3000] -} -``` - -## Good - -```jsonc -{ - "name": "my project", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "forwardPorts": [3000] -} -``` - -## References - -- -- diff --git a/docs/content/rules/missing-feature-install-script.md b/docs/content/rules/missing-feature-install-script.md deleted file mode 100644 index 977e81d..0000000 --- a/docs/content/rules/missing-feature-install-script.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: missing-feature-install-script -category: correctness -platforms: [] -file_types: [feature] -description: >- - disallow a Feature directory without the required `install.sh` install - script -example_verify: false ---- - -## Why - -A Feature is distributed as its metadata file plus the "install.sh" the tooling runs inside the container, -which is where the Feature does all of its work. A directory without one publishes a Feature that -installs nothing, and the omission only surfaces when someone builds a container with it. - -## Bad - -```text -src/node/ -└── devcontainer-feature.json -``` - -## Good - -```text -src/node/ -├── devcontainer-feature.json -└── install.sh -``` - -The name is fixed: the tooling runs `install.sh` and nothing else, so an -install script under any other name is never executed. - -## References - -- -- diff --git a/docs/content/rules/missing-required-props.md b/docs/content/rules/missing-required-props.md deleted file mode 100644 index 5a4fbdc..0000000 --- a/docs/content/rules/missing-required-props.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: missing-required-props -category: correctness -platforms: [] -file_types: [feature, template] -description: >- - disallow a Feature's or Template's metadata that is missing a required - property ("id", "version", or "name") ---- - -## Why - -"id", "version", and "name" are the only properties either specification requires: the "id" addresses the -artifact, the "version" is what consumers pin to, and the "name" is what a user recognizes it by in a -list. Metadata missing any of them cannot be published as a usable Feature or Template. - -## Bad - -```jsonc -{ - "id": "node", - "version": "1.0.0" -} -``` - -## Good - -```jsonc -{ - "id": "node", - "version": "1.0.0", - "name": "Node.js" -} -``` - -## References - -- -- diff --git a/docs/content/rules/missing-workspace-mount-folder.md b/docs/content/rules/missing-workspace-mount-folder.md deleted file mode 100644 index f854eb4..0000000 --- a/docs/content/rules/missing-workspace-mount-folder.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: missing-workspace-mount-folder -category: correctness -platforms: [] -file_types: [devcontainer] -description: >- - disallow a devcontainer.json using "image" or "build" that sets only one - of "workspaceMount" or "workspaceFolder" ---- - -## Why - -The two properties describe opposite ends of the same override: "workspaceMount" says where the source -code is mounted, "workspaceFolder" says which path inside the container the tooling opens. The reference -documents each as requiring the other, because setting one alone either mounts the source somewhere -nothing opens, or opens a path nothing is mounted at. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "workspaceMount": "source=${localWorkspaceFolder},target=/srv/app,type=bind" -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "workspaceMount": "source=${localWorkspaceFolder},target=/srv/app,type=bind", - "workspaceFolder": "/srv/app" -} -``` - -## References - -- -- diff --git a/docs/content/rules/no-app-port.md b/docs/content/rules/no-app-port.md deleted file mode 100644 index c75a3b1..0000000 --- a/docs/content/rules/no-app-port.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: no-app-port -category: style -platforms: [] -file_types: [devcontainer] -description: >- - disallow the legacy "appPort" property in favor of "forwardPorts" ---- - -## Why - -"appPort" publishes the port the way Docker does: it is fixed when the container is created, and the -application has to listen on all interfaces rather than just "localhost" to be reachable. A forwarded -port instead looks like "localhost" to the application and can be changed without recreating the -container, which is why the reference recommends "forwardPorts" in most cases. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "appPort": [3000] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "forwardPorts": [3000] -} -``` - -## References - -- -- diff --git a/docs/content/rules/no-bind-mount.md b/docs/content/rules/no-bind-mount.md deleted file mode 100644 index c2ab9b5..0000000 --- a/docs/content/rules/no-bind-mount.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: no-bind-mount -category: correctness -platforms: [codespaces] -file_types: [devcontainer] -description: >- - disallow "bind" type entries in "mounts", which GitHub Codespaces silently - ignores except for the Docker socket ---- - -## Why - -A codespace runs on a machine in the cloud, where the host path a bind mount points at does not exist, so -Codespaces documents that it ignores "bind" mounts apart from the Docker socket. The mount is dropped -without an error and the container starts missing the data it expects. Volume mounts are honored, so use -"type=volume" for anything that only has to persist across rebuilds. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "mounts": [ - { - "source": "${localWorkspaceFolder}/.cache", - "target": "/home/vscode/.cache", - "type": "bind" - } - ] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "mounts": [ - { - "source": "devcontainer-cache", - "target": "/home/vscode/.cache", - "type": "volume" - } - ] -} -``` - -## References - -- -- diff --git a/docs/content/rules/no-cap-add-all.md b/docs/content/rules/no-cap-add-all.md deleted file mode 100644 index 8facbcf..0000000 --- a/docs/content/rules/no-cap-add-all.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: no-cap-add-all -category: security -platforms: [] -file_types: [devcontainer, feature] -description: >- - disallow granting all Linux capabilities via an "ALL" entry in the - "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's - "runArgs" ---- - -## Why - -Linux capabilities split root's powers into units a container can be granted individually, and the runtime -withholds the dangerous ones by default. "ALL" hands them all over, including capabilities such as -"SYS_ADMIN" and "SYS_MODULE" that let a process reconfigure the host kernel and escape the container. -"capAdd" exists to name the one or two a workload actually needs, e.g. "SYS_PTRACE" for a debugger. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "capAdd": ["ALL"] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "capAdd": ["SYS_PTRACE"] -} -``` - -## References - -- -- diff --git a/docs/content/rules/no-docker-socket-mount.md b/docs/content/rules/no-docker-socket-mount.md deleted file mode 100644 index 69384ee..0000000 --- a/docs/content/rules/no-docker-socket-mount.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: no-docker-socket-mount -category: security -platforms: [] -file_types: [devcontainer] -description: >- - disallow bind-mounting the host's Docker socket via a devcontainer.json's - "mounts" or "runArgs", which grants the container root-equivalent control - over the host ---- - -## Why - -The Docker socket is the daemon's full API, and the daemon runs as root on the host. Anything that can -reach the socket can start a container that mounts the host's filesystem, so mounting it into the dev -container hands root-equivalent control of the host to every process inside — including code the -project's own build fetches. When the container genuinely needs Docker, a Docker-in-Docker Feature or a -rootless daemon keeps that access inside the container. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "mounts": [ - { - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "type": "bind" - } - ] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2.13.0": {} - } -} -``` - -## References - -- -- diff --git a/docs/content/rules/no-host-port-format.md b/docs/content/rules/no-host-port-format.md deleted file mode 100644 index 84f4ba6..0000000 --- a/docs/content/rules/no-host-port-format.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: no-host-port-format -category: correctness -platforms: [codespaces] -file_types: [devcontainer] -description: >- - disallow "host:port" entries in "forwardPorts" and "portsAttributes", - which GitHub Codespaces does not support ---- - -## Why - -The "host:port" form forwards a port from another container in a Docker Compose project (e.g. "db:5432") -rather than from the primary one. Codespaces documents that it does not support that variation of either -property, so the entry is ignored there and the port is not forwarded. A bare port number, which refers -to the primary container, works everywhere. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "forwardPorts": ["db:5432"] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "forwardPorts": [5432] -} -``` - -## References - -- -- diff --git a/docs/content/rules/no-image-latest.md b/docs/content/rules/no-image-latest.md deleted file mode 100644 index 301f80c..0000000 --- a/docs/content/rules/no-image-latest.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: no-image-latest -category: reproducibility -platforms: [] -file_types: [devcontainer] -description: >- - disallow container images without an explicit tag or with the "latest" tag ---- - -## Why - -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. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:latest" -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" -} -``` - -## References - -- diff --git a/docs/content/rules/no-privileged-container.md b/docs/content/rules/no-privileged-container.md deleted file mode 100644 index 4bdbf5c..0000000 --- a/docs/content/rules/no-privileged-container.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: no-privileged-container -category: security -platforms: [] -file_types: [devcontainer, feature] -description: >- - disallow running the container in privileged mode via the "privileged" - property or a "--privileged" entry in "runArgs" ---- - -## Why - -A privileged container gets every Linux capability, unconfined seccomp and LSM profiles, and access to all -host devices. That removes essentially every boundary between the container and the host, so any code -running in it — including a compromised dependency pulled in by the project's own build — can take over -the machine. Docker-in-Docker is the usual reason it is set; a Feature that provides it, or the specific -capabilities and devices the workload needs, is a far narrower grant. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "privileged": true -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "capAdd": ["SYS_PTRACE"] -} -``` - -The good example grants only the capability a debugger needs. Reach for the -narrowest grant that works: `capAdd` for a capability, `--device` in `runArgs` -for a device, and the docker-in-docker Feature rather than privileged mode for -nested containers. - -## References - -- -- diff --git a/docs/content/rules/no-seccomp-override.md b/docs/content/rules/no-seccomp-override.md deleted file mode 100644 index c6606cb..0000000 --- a/docs/content/rules/no-seccomp-override.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: no-seccomp-override -category: security -platforms: [] -file_types: [devcontainer, feature] -description: >- - disallow overriding the container runtime's default seccomp profile via a - devcontainer.json's or Feature's "securityOpt" property, or a - "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs" ---- - -## Why - -The runtime's default seccomp profile blocks the syscalls containers do not need, several of which have -featured in container escapes. Pointing "seccomp" at a profile of your own replaces that default -wholesale, and a hand-written profile is rarely reviewed as carefully or updated as the kernel gains new -syscalls. Keep the default unless the workload provably needs more, and review the replacement if it -does. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "securityOpt": ["seccomp=./seccomp.json"] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "capAdd": ["SYS_PTRACE"] -} -``` - -Leaving `securityOpt` unset keeps the runtime's default seccomp profile, -which already allows what a development container normally does. - -## References - -- -- diff --git a/docs/content/rules/no-seccomp-unconfined.md b/docs/content/rules/no-seccomp-unconfined.md deleted file mode 100644 index 712aa8b..0000000 --- a/docs/content/rules/no-seccomp-unconfined.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: no-seccomp-unconfined -category: security -platforms: [] -file_types: [devcontainer, feature] -description: >- - disallow disabling seccomp confinement via a devcontainer.json's or - Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" - entry in a devcontainer.json's "runArgs" ---- - -## Why - -"seccomp=unconfined" turns off syscall filtering entirely, exposing the whole kernel API — including the -calls the default profile blocks precisely because they have been used to break out of containers. The -setting is most often copied from debugger instructions, where granting the "SYS_PTRACE" capability is -enough on current runtimes. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "securityOpt": ["seccomp=unconfined"] -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "capAdd": ["SYS_PTRACE"] -} -``` - -## References - -- -- diff --git a/docs/content/rules/pin-extension-version.md b/docs/content/rules/pin-extension-version.md deleted file mode 100644 index 1eb5d35..0000000 --- a/docs/content/rules/pin-extension-version.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: pin-extension-version -category: reproducibility -platforms: [vscode, codespaces] -file_types: [devcontainer] -description: >- - disallow a "customizations.vscode.extensions" entry without an explicit - pinned version ---- - -## Why - -An extension ID on its own installs whatever the marketplace publishes at the moment the container is -created, so two developers on the same devcontainer.json can end up with different formatters, linters, or -language server versions — and an extension update can change the environment without any commit. -Appending a version ("publisher.name@1.2.3") makes the editor tooling as pinned as the rest of the image. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "customizations": { - "vscode": { - "extensions": ["golang.go"] - } - } -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "customizations": { - "vscode": { - "extensions": ["golang.go@0.50.0"] - } - } -} -``` - -## References - -- -- diff --git a/docs/content/rules/pin-feature-version.md b/docs/content/rules/pin-feature-version.md deleted file mode 100644 index 4f761fb..0000000 --- a/docs/content/rules/pin-feature-version.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: pin-feature-version -category: reproducibility -platforms: [] -file_types: [devcontainer] -description: >- - disallow a Feature reference without an explicit version or with the - "latest" version ---- - -## Why - -A Feature reference with no version resolves to "latest", so the container installs whatever the Feature's -author published most recently — the tooling it sets up can change under the project without the -devcontainer.json changing at all. Features are published under their full version as well as -"major.minor" and "major" tags, so a reference can be pinned as tightly as the project wants. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "ghcr.io/devcontainers/features/go": {} - } -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "ghcr.io/devcontainers/features/go:1.3.2": {} - } -} -``` - -## References - -- -- diff --git a/docs/content/rules/pin-image-digest.md b/docs/content/rules/pin-image-digest.md deleted file mode 100644 index 6fb9353..0000000 --- a/docs/content/rules/pin-image-digest.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: pin-image-digest -category: reproducibility -platforms: [] -file_types: [devcontainer] -description: >- - disallow an "image" property that does not pin the image by content digest - (e.g. "image@sha256:...") ---- - -## Why - -A tag is a mutable pointer: the publisher can move even a fully specified one to different bits, and a -registry can serve a different image for the same tag on a different day. A digest names the content -itself, so "image@sha256:..." always resolves to the exact image the project was tested with, and the -client verifies what it pulled against it. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:2a1d1e1a4b0c3f8e5c8a1e0a6d3b7c9f4e2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b" -} -``` - -## References - -- -- diff --git a/docs/content/rules/require-cap-drop-all.md b/docs/content/rules/require-cap-drop-all.md deleted file mode 100644 index f7de108..0000000 --- a/docs/content/rules/require-cap-drop-all.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: require-cap-drop-all -category: security -platforms: [] -file_types: [devcontainer] -description: >- - require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", - dropping every Linux capability ---- - -## Why - -Container runtimes grant a default set of capabilities that a dev container almost never uses: raw network -access, changing file ownership, or binding privileged ports. Dropping all of them and adding back only -what the workload needs ("capAdd") means a process that is compromised inherits no privilege the project -never asked for. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu" -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "runArgs": ["--cap-drop=ALL"], - "capAdd": ["CHOWN", "SETUID", "SETGID"] -} -``` - -`runArgs` is the only place this can be expressed: devcontainer.json has a -`capAdd` property but no `capDrop` one, so dropping capabilities means passing -the flag to the container runtime. Add back through `capAdd` whatever the -workload actually needs. - -## References - -- -- diff --git a/docs/content/rules/require-no-new-privileges.md b/docs/content/rules/require-no-new-privileges.md deleted file mode 100644 index f2578b1..0000000 --- a/docs/content/rules/require-no-new-privileges.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: require-no-new-privileges -category: security -platforms: [] -file_types: [devcontainer] -description: >- - require "no-new-privileges" to be set via a devcontainer.json's - "securityOpt" property, or a "--security-opt no-new-privileges..." entry - in "runArgs" ---- - -## Why - -Without this option a process in the container can still gain privileges it was not started with, by -executing a setuid binary — which undercuts the point of running as a non-root user. Setting it raises the -kernel's "no_new_privs" bit, which every child process inherits and none can clear, so the container's -privileges can only ever shrink. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu" -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "securityOpt": ["no-new-privileges"] -} -``` - -## References - -- -- diff --git a/docs/content/rules/require-non-root.md b/docs/content/rules/require-non-root.md deleted file mode 100644 index 1bab85c..0000000 --- a/docs/content/rules/require-non-root.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: require-non-root -category: security -platforms: [] -file_types: [devcontainer] -description: >- - require "remoteUser" or, if unset, "containerUser" to be set to a non-root - user ---- - -## Why - -"remoteUser" defaults to whatever user the container runs as, which for most images is root. Everything -the developer's session drives then runs as root: lifecycle scripts, terminals, and the language servers -and build tools the editor starts, so a compromised dependency runs with full control of the container. -Naming an unprivileged user — as the specification's own images do — costs nothing and contains it. - -## Bad - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "remoteUser": "root" -} -``` - -## Good - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "remoteUser": "vscode" -} -``` - -`remoteUser` is what lifecycle scripts and the editor's remote session run as, -and it wins over `containerUser`; a rule that finds neither reports the -container's default, which is `root` for most images. - -## References - -- -- diff --git a/docs/content/rules/undefined-template-option.md b/docs/content/rules/undefined-template-option.md deleted file mode 100644 index 66d4ec0..0000000 --- a/docs/content/rules/undefined-template-option.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: undefined-template-option -category: correctness -platforms: [] -file_types: [template] -description: >- - disallow a `${templateOption:...}` reference to an option not declared in - devcontainer-template.json -example_dir: dotnet ---- - -## Why - -Applying a Template replaces each "${templateOption:name}" with the value the user chose for the option of -that name. A reference to an option that "options" does not declare is never prompted for, and the -reference implementation substitutes the empty string for it, so a typo silently produces an empty value -in the applied files instead of an error. - -## Bad - -### `devcontainer-template.json` - -```jsonc -{ - "id": "dotnet", - "version": "1.0.0", - "name": "C# (.NET)", - "options": { - "imageVariant": { - "type": "string", - "proposals": ["8.0", "9.0"], - "default": "9.0" - } - } -} -``` - -### `.devcontainer/devcontainer.json` - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:variant}" -} -``` - -## Good - -### `devcontainer-template.json` - -```jsonc -{ - "id": "dotnet", - "version": "1.0.0", - "name": "C# (.NET)", - "options": { - "imageVariant": { - "type": "string", - "proposals": ["8.0", "9.0"], - "default": "9.0" - } - } -} -``` - -### `.devcontainer/devcontainer.json` - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:imageVariant}" -} -``` - -## References - -- -- diff --git a/docs/content/rules/unused-template-option.md b/docs/content/rules/unused-template-option.md deleted file mode 100644 index d5ed7a1..0000000 --- a/docs/content/rules/unused-template-option.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: unused-template-option -category: style -platforms: [] -file_types: [template] -description: >- - disallow a Template option that no file in the Template references -example_dir: dotnet ---- - -## Why - -An option only takes effect where a file substitutes it as "${templateOption:name}". One that nothing -references is still presented to the user when the Template is applied, so it asks a question whose -answer changes nothing — usually a leftover from a removed file or a renamed reference. - -## Bad - -### `devcontainer-template.json` - -```jsonc -{ - "id": "dotnet", - "version": "1.0.0", - "name": "C# (.NET)", - "options": { - "imageVariant": { - "type": "string", - "proposals": ["8.0", "9.0"], - "default": "9.0" - } - } -} -``` - -### `.devcontainer/devcontainer.json` - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/dotnet:9.0" -} -``` - -## Good - -### `devcontainer-template.json` - -```jsonc -{ - "id": "dotnet", - "version": "1.0.0", - "name": "C# (.NET)", - "options": { - "imageVariant": { - "type": "string", - "proposals": ["8.0", "9.0"], - "default": "9.0" - } - } -} -``` - -### `.devcontainer/devcontainer.json` - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:imageVariant}" -} -``` - -## References - -- -- diff --git a/docs/hugo.toml b/docs/hugo.toml index 881dff1..34ad719 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -16,6 +16,28 @@ disableKinds = ["taxonomy", "term", "RSS"] description = "A linter for Dev Container configuration files." repository = "https://github.com/bare-devcontainer/decolint" +# Most content is generated by "go run ./cmd/docgen" (see the Makefile) into .generated/content — +# the rule pages from rules/*.go, and the landing/getting-started/reference pages from README.md — +# so it is mounted alongside the handful of pages (docs/content/rules/_index.md) that are still +# hand-written. Declaring any module mount opts out of Hugo's implicit defaults, so every directory +# the site needs is listed here. +[module] + [[module.mounts]] + source = "content" + target = "content" + [[module.mounts]] + source = ".generated/content" + target = "content" + [[module.mounts]] + source = "layouts" + target = "layouts" + [[module.mounts]] + source = "assets" + target = "assets" + [[module.mounts]] + source = "data" + target = "data" + [markup.highlight] # Emit token classes rather than inline styles, so the colors live in the site's own stylesheet # and follow the reader's light or dark preference. @@ -43,3 +65,8 @@ disableKinds = ["taxonomy", "term", "RSS"] name = "Rules" pageRef = "/rules" weight = 20 + +[[menus.main]] + name = "Reference" + pageRef = "/reference" + weight = 30 diff --git a/docs/layouts/page.html b/docs/layouts/page.html index a94a294..e9eb49d 100644 --- a/docs/layouts/page.html +++ b/docs/layouts/page.html @@ -14,6 +14,9 @@

{{ .Title }}

{{ with .Params.platforms }}{{ delimit . ", " }}{{ else }}all{{ end }}
{{ end }} + {{ if .Params.toc }} + + {{ end }} {{ .Content }} {{ end }} diff --git a/format/rules.go b/format/rules.go new file mode 100644 index 0000000..d79d7d1 --- /dev/null +++ b/format/rules.go @@ -0,0 +1,41 @@ +package format + +// RuleDoc is the full documentation of one built-in rule. It is the wire shape of +// "decolint -rules -format=json", and is what the documentation site is generated from (see +// cmd/docgen), so a reader of the JSON and a reader of the site see the same data. +type RuleDoc struct { + ID string `json:"id"` + Description string `json:"description"` + LongDescription string `json:"longDescription"` + References []string `json:"references"` + Category string `json:"category"` + Platforms []string `json:"platforms"` + FileTypes []string `json:"fileTypes"` + Example RuleExample `json:"example"` + // DocsURL is where the rule's page is published (see rules.DocsURL). + DocsURL string `json:"docsUrl"` + // Severity is the severity the rule is currently registered at: its category's default, unless + // overridden by the config file in effect. + Severity string `json:"severity"` +} + +// RuleExample is a [linter.Example] adapted for JSON. +type RuleExample struct { + Bad RuleSnippet `json:"bad"` + Good RuleSnippet `json:"good"` + Note string `json:"note,omitzero"` +} + +// RuleSnippet is a [linter.Snippet] adapted for JSON. +type RuleSnippet struct { + Files []RuleExampleFile `json:"files"` + DirName string `json:"dirName,omitzero"` +} + +// RuleExampleFile is a [linter.ExampleFile] adapted for JSON. +type RuleExampleFile struct { + Path string `json:"path"` + Content string `json:"content"` + // Mode is the file's POSIX permission bits (e.g. 420 for 0644), 0 meaning the default. + Mode uint32 `json:"mode,omitzero"` +} diff --git a/linter/rule.go b/linter/rule.go index a076d1f..c76cbf4 100644 --- a/linter/rule.go +++ b/linter/rule.go @@ -262,6 +262,13 @@ type Rule struct { ID string // Description is a short human-readable description of what the rule checks. Description string + // LongDescription explains why the rule exists: what goes wrong in the configuration it + // reports, and what to do instead. It is Markdown, and is shown wherever the rule is + // documented rather than merely named. + LongDescription string + // References are URLs to the specification, documentation, or implementation that justify the + // rule, most authoritative first. + References []string // Category is the [Category] this rule reports; every rule must declare exactly one. Category Category // FileTypes are the kinds of configuration files this rule applies to. @@ -274,8 +281,49 @@ type Rule struct { // matches any object member name or array index (e.g. "/mounts/*"); the empty string matches the // document root. Paths []string + // Example shows the rule firing and not firing on realistic configuration. Tests lint both: Bad + // must report the rule, Good must not. + Example Example // Check inspects one value matching Paths and returns any findings. It is called at most once per // rule for a given value, even if several patterns match it. Check must be safe for concurrent // use, since it may be called for multiple files. Check func(ctx *Context, node *Node) []Finding } + +// Example pairs configuration that trips a rule with configuration that doesn't, for the rule's +// documentation. +type Example struct { + // Bad is configuration the rule reports. + Bad Snippet + // Good is configuration the rule does not report, typically Bad with the problem fixed. + Good Snippet + // Note is Markdown prose shown after Good, for context Bad and Good alone don't convey (e.g. why + // Good is scoped the way it is). It is optional. + Note string +} + +// Snippet is the directory an example is linted in: one or more files, and optionally the +// directory's own name, for a rule that reads it (e.g. [Dir.Name]). +type Snippet struct { + // Files are the snippet's files. Exactly one must have the path a rule's first FileType is + // named at (e.g. "devcontainer.json" for [Devcontainer]); that is the file linted, and it is + // also the one shown first. The rest are context a rule reads from the directory, e.g. a + // devcontainer-template.json a devcontainer.json's ${templateOption:...} reference is checked + // against. + Files []ExampleFile + // DirName is the directory's own name, for a rule that reads [Dir.Name] (e.g. a Feature's or + // Template's id must match the directory containing it). It is empty when no rule needs it. + DirName string +} + +// ExampleFile is one file of a [Snippet]. +type ExampleFile struct { + // Path is the file's path, relative to the directory the example is linted in (e.g. + // "devcontainer.json" or ".devcontainer/devcontainer.json"). + Path string + // Content is the file's content. + Content string + // Mode is the file's permission bits. The zero value means a regular file at the default mode; + // set it for a rule that inspects permissions (e.g. whether install.sh is executable). + Mode fs.FileMode +} diff --git a/rules/conflicting_container_def.go b/rules/conflicting_container_def.go index b8740b6..bc39cba 100644 --- a/rules/conflicting_container_def.go +++ b/rules/conflicting_container_def.go @@ -11,10 +11,43 @@ import ( var ConflictingContainerDef = &linter.Rule{ ID: "conflicting-container-def", Description: `disallow a devcontainer.json that defines more than one of "image", "build", or "dockerComposeFile"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkConflictingContainerDef, + LongDescription: `The specification defines three mutually exclusive ways to create the container: from an image, from a +Dockerfile, or from a Docker Compose project. Which one wins when several are set is unspecified, so the +container that gets built depends on the tool rather than on the configuration. Keep the variant the +project actually uses and remove the others.`, + References: []string{ + `https://containers.dev/implementors/spec/#orchestration-options`, + `https://containers.dev/implementors/json_reference/#scenario-specific-properties`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "build": { + "dockerfile": "Dockerfile" + } +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "build": { + "dockerfile": "Dockerfile" + } +} +`}, + }, + }, + }, + Check: checkConflictingContainerDef, } func checkConflictingContainerDef(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/doc_test.go b/rules/doc_test.go index 70ae314..3e6f45b 100644 --- a/rules/doc_test.go +++ b/rules/doc_test.go @@ -2,9 +2,7 @@ package rules_test import ( "net/url" - "os" - "path" - "path/filepath" + "runtime" "slices" "strings" "testing" @@ -12,33 +10,13 @@ import ( "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" - "github.com/google/go-cmp/cmp" ) -// Every built-in rule is documented by a page under docsDir, published at [rules.DocsURL] and -// linked from every finding decolint reports. These tests are what keeps a rule from landing -// undocumented, a page from drifting from the rule it describes, and an example from claiming -// behavior the rule does not have. - -// docsDir holds the rule reference, one Markdown page per rule ID, relative to this package. -const docsDir = "../docs/content/rules" - -// docsFileNames maps a file type to the name a page's examples are linted under, which is the name -// the specification gives that kind of configuration file. -var docsFileNames = map[linter.FileType]string{ - linter.Devcontainer: "devcontainer.json", - linter.Feature: "devcontainer-feature.json", - linter.Template: "devcontainer-template.json", -} - -// unverifiableExamples are the rules whose Bad and Good examples differ in something other than the -// contents of a configuration file — a file's permission bits, or whether it exists at all — and so -// cannot be checked by linting them. Listing them here rather than skipping silently keeps the set -// from growing unnoticed. -var unverifiableExamples = []string{ - "feature-install-script-not-executable", - "missing-feature-install-script", -} +// Every built-in rule documents itself on its [linter.Rule]: a short Description, a LongDescription +// explaining why it exists, References that justify it, and an Example whose Bad configuration the +// rule must report and whose Good configuration it must not. These tests are what keeps a new rule +// from landing undocumented or shipping an example that doesn't actually demonstrate the rule, and +// what the documentation site (see cmd/docgen) and "decolint -rules -format=json" are generated from. func TestBuiltin_Descriptions(t *testing.T) { t.Parallel() @@ -47,303 +25,106 @@ func TestBuiltin_Descriptions(t *testing.T) { if strings.TrimSpace(reg.Rule.Description) == "" { t.Errorf("rule %s has no Description", reg.Rule.ID) } - } -} - -// TestBuiltin_DocsPages checks that the rule reference and the rule registry describe the same set -// of rules, and that each page's front matter matches the rule it documents. The front matter is -// what the site renders the page's heading and index entry from, so a stale value is published. -func TestBuiltin_DocsPages(t *testing.T) { - t.Parallel() - - pages := readDocsPages(t) - var documented []string - for id := range pages { - documented = append(documented, id) - } - slices.Sort(documented) - - var registered []string - for _, reg := range rules.Builtin() { - registered = append(registered, reg.Rule.ID) - } - slices.Sort(registered) - - if diff := cmp.Diff(registered, documented); diff != "" { - t.Errorf("documented rules differ from registered rules (-registered +documented):\n%s", diff) - } - - for _, reg := range rules.Builtin() { - page, ok := pages[reg.Rule.ID] - if !ok { - continue // already reported by the diff above - } - want := map[string]string{ - "title": reg.Rule.ID, - "category": reg.Rule.Category.String(), - "platforms": strings.Join(platformNames(reg.Rule.Platforms), ", "), - "file_types": strings.Join(fileTypeNames(reg.Rule.FileTypes), ", "), - "description": reg.Rule.Description, - } - got := map[string]string{ - "title": page.frontMatter["title"], - "category": page.frontMatter["category"], - "platforms": page.frontMatter["platforms"], - "file_types": page.frontMatter["file_types"], - "description": page.frontMatter["description"], + if strings.TrimSpace(reg.Rule.LongDescription) == "" { + t.Errorf("rule %s has no LongDescription", reg.Rule.ID) } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("rule %s front matter mismatch (-rule +page):\n%s", reg.Rule.ID, diff) + if got := reg.Rule.LongDescription; got != strings.TrimSpace(got) { + t.Errorf("rule %s LongDescription has leading or trailing whitespace", reg.Rule.ID) } } } -// TestBuiltin_DocsExamples lints each page's Bad and Good examples with only the rule the page -// documents enabled: the Bad example must report it and the Good example must not. It is what stops -// an example from teaching configuration the rule does not actually accept or reject. -func TestBuiltin_DocsExamples(t *testing.T) { +func TestBuiltin_References(t *testing.T) { t.Parallel() - pages := readDocsPages(t) - var skipped []string for _, reg := range rules.Builtin() { - page, ok := pages[reg.Rule.ID] - if !ok { - continue // reported by TestBuiltin_DocsPages - } - if !page.verifiable { - skipped = append(skipped, reg.Rule.ID) - continue + if len(reg.Rule.References) == 0 { + t.Errorf("rule %s has no References", reg.Rule.ID) } - - t.Run(reg.Rule.ID, func(t *testing.T) { - t.Parallel() - - if issues := lintDocsExample(t, reg.Rule, page, page.bad); len(issues) == 0 { - t.Errorf("Bad example reports nothing; it must trip %s", reg.Rule.ID) - } - if issues := lintDocsExample(t, reg.Rule, page, page.good); len(issues) > 0 { - t.Errorf("Good example reports %d issue(s), want none: %v", len(issues), issues) - } - }) - } - - slices.Sort(skipped) - if diff := cmp.Diff(unverifiableExamples, skipped); diff != "" { - t.Errorf("unverifiable examples differ from the documented set (-want +got):\n%s", diff) - } -} - -// TestBuiltin_DocsReferences checks the links each page cites as its justification: a reader has to -// be able to follow them, so each must be an absolute https URL, and citing one twice is a mistake. -func TestBuiltin_DocsReferences(t *testing.T) { - t.Parallel() - - for id, page := range readDocsPages(t) { - if len(page.references) == 0 { - t.Errorf("rule %s documents no references", id) - } - for _, ref := range page.references { + for _, ref := range reg.Rule.References { + // A reference is rendered as a link wherever it is shown, so it has to be a URL a reader + // can follow on its own, not a bare path or a prose citation. u, err := url.Parse(ref) if err != nil { - t.Errorf("rule %s reference %q does not parse: %v", id, ref, err) + t.Errorf("rule %s reference %q does not parse: %v", reg.Rule.ID, ref, err) continue } if u.Scheme != "https" || u.Host == "" { - t.Errorf("rule %s reference %q is not an absolute https URL", id, ref) + t.Errorf("rule %s reference %q is not an absolute https URL", reg.Rule.ID, ref) } } - if i := duplicateIndex(page.references); i >= 0 { - t.Errorf("rule %s cites reference %q twice", id, page.references[i]) + if i := duplicateIndex(reg.Rule.References); i >= 0 { + t.Errorf("rule %s lists reference %q twice", reg.Rule.ID, reg.Rule.References[i]) } } } -func TestDocsURL(t *testing.T) { - t.Parallel() - - got := rules.DocsURL("no-image-latest") - want := "https://bare-devcontainer.github.io/decolint/rules/no-image-latest/" - if got != want { - t.Errorf("DocsURL = %q, want %q", got, want) - } -} - -// docsPage is one rule's documentation page. -type docsPage struct { - frontMatter map[string]string - // bad and good are the files making up each example, in the order the page shows them. - bad, good []docsFile - // verifiable reports whether the examples can be checked by linting them; see - // unverifiableExamples. - verifiable bool - references []string -} - -// docsFile is one file of an example: the name it is linted under and its contents. -type docsFile struct { - name, source string -} - -// readDocsPages parses every rule page in docsDir, keyed by the rule ID its file is named after. -func readDocsPages(t *testing.T) map[string]docsPage { - t.Helper() - - entries, err := os.ReadDir(docsDir) - if err != nil { - t.Fatalf("read %s: %v", docsDir, err) - } - - pages := make(map[string]docsPage, len(entries)) - for _, e := range entries { - name := e.Name() - // A leading underscore marks the section's own list page, not a rule. - if e.IsDir() || filepath.Ext(name) != ".md" || strings.HasPrefix(name, "_") { - continue - } - src, err := os.ReadFile(filepath.Join(docsDir, name)) - if err != nil { - t.Fatalf("read %s: %v", name, err) - } - pages[strings.TrimSuffix(name, ".md")] = parseDocsPage(t, name, string(src)) - } - if len(pages) == 0 { - t.Fatalf("no rule pages found in %s", docsDir) - } - return pages +// defaultFileName is the file name a rule's own configuration file is expected at, mirroring +// discovery's naming for each [linter.FileType]. +var defaultFileName = map[linter.FileType]string{ + linter.Devcontainer: "devcontainer.json", + linter.Feature: "devcontainer-feature.json", + linter.Template: "devcontainer-template.json", } -// parseDocsPage parses a rule page: YAML front matter delimited by "---" lines, then "## Bad", -// "## Good" and "## References" sections. Within a section, a fenced jsonc block is one file of the -// example, named by the "### `name`" heading before it or, with no heading, by the rule's own file -// type. -func parseDocsPage(t *testing.T, name, src string) docsPage { - t.Helper() - - body, ok := strings.CutPrefix(src, "---\n") - if !ok { - t.Fatalf("%s: no front matter", name) - } - fm, body, ok := strings.Cut(body, "\n---\n") - if !ok { - t.Fatalf("%s: front matter is not terminated", name) - } +// TestBuiltin_Examples lints each rule's Example.Bad and Example.Good, exercising the same +// path-matching and traversal logic the linter uses in production: Bad must report the rule and Good +// must not, so an example cannot drift from the rule it documents. +func TestBuiltin_Examples(t *testing.T) { + t.Parallel() - page := docsPage{frontMatter: parseFrontMatter(t, name, fm)} - page.verifiable = page.frontMatter["example_verify"] != "false" + for _, reg := range rules.Builtin() { + t.Run(reg.Rule.ID, func(t *testing.T) { + t.Parallel() - var section, fileName, fence string - var block strings.Builder - for line := range strings.SplitSeq(body, "\n") { - if fence != "" { - if strings.TrimSpace(line) == fence { - if fileName != "" { - file := docsFile{name: fileName, source: block.String()} - switch section { - case "Bad": - page.bad = append(page.bad, file) - case "Good": - page.good = append(page.good, file) - } - } - fence, fileName = "", "" - block.Reset() - continue + // The exec-bit rule reports nothing on Windows by design (see + // checkFeatureInstallScriptNotExecutable); its Bad example would report zero findings + // there too, which would otherwise look like example drift rather than the documented + // platform limitation. + if reg.Rule.ID == "feature-install-script-not-executable" && runtime.GOOS == "windows" { + t.Skip("this rule does not run on Windows") } - block.WriteString(line + "\n") - continue - } - switch { - case strings.HasPrefix(line, "## "): - section = strings.TrimSpace(strings.TrimPrefix(line, "## ")) - case strings.HasPrefix(line, "### "): - fileName = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "### ")), "`") - case strings.HasPrefix(line, "```jsonc"): - fence = "```" - if fileName == "" { - fileName = defaultDocsFileName(t, name, page.frontMatter["file_types"]) - } - case strings.HasPrefix(line, "```"): - // A block in another language documents something that is not a configuration file, so - // it is shown to the reader but never linted. - fence = "```" - fileName = "" - case section == "References" && strings.HasPrefix(line, "- <"): - page.references = append(page.references, strings.Trim(strings.TrimPrefix(line, "- "), "<>")) - } - } - return page -} -// parseFrontMatter parses the subset of YAML the rule pages use: "key: value", a flow sequence -// "key: [a, b]" kept as its comma-separated contents, and a folded block scalar "key: >-" whose -// indented continuation lines are joined with single spaces. -func parseFrontMatter(t *testing.T, name, src string) map[string]string { - t.Helper() + if len(reg.Rule.Example.Bad.Files) == 0 || len(reg.Rule.Example.Good.Files) == 0 { + t.Fatalf("rule %s has an empty Example", reg.Rule.ID) + } - out := map[string]string{} - var folding string - for line := range strings.SplitSeq(src, "\n") { - if folding != "" { - if strings.HasPrefix(line, " ") { - out[folding] = strings.TrimSpace(out[folding] + " " + strings.TrimSpace(line)) - continue + if issues := lintExample(t, reg.Rule, reg.Rule.Example.Bad); len(issues) == 0 { + t.Errorf("Example.Bad reports nothing; it must trip %s", reg.Rule.ID) } - folding = "" - } - key, value, ok := strings.Cut(line, ":") - if !ok { - continue - } - key, value = strings.TrimSpace(key), strings.TrimSpace(value) - switch { - case value == ">-": - folding = key - out[key] = "" - case strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]"): - out[key] = strings.TrimSuffix(strings.TrimPrefix(value, "["), "]") - default: - out[key] = value - } - } - if len(out) == 0 { - t.Fatalf("%s: front matter has no fields", name) + if issues := lintExample(t, reg.Rule, reg.Rule.Example.Good); len(issues) > 0 { + t.Errorf("Example.Good reports %d issue(s), want none: %v", len(issues), issues) + } + }) } - return out } -// defaultDocsFileName returns the file name an unlabelled example block is linted under: the name -// of the first file type in the page's front matter, which is the kind of file the rule's own -// examples are written as. -func defaultDocsFileName(t *testing.T, name, fileTypes string) string { +// lintExample lints snip with r as the only active rule, registered at [linter.SeverityError], and +// returns what it reports. Every file in snip is visible to a rule that reads sibling files; the one +// named after r's own file type is the one linted. +func lintExample(t *testing.T, r *linter.Rule, snip linter.Snippet) []linter.Issue { t.Helper() - first, _, _ := strings.Cut(fileTypes, ",") - fileName, ok := docsFileNames[linter.FileType(strings.TrimSpace(first))] + fileName, ok := defaultFileName[r.FileTypes[0]] if !ok { - t.Fatalf("%s: front matter names no known file type, got %q", name, fileTypes) + t.Fatalf("rule %s FileTypes[0] %q has no default file name", r.ID, r.FileTypes[0]) } - return fileName -} - -// lintDocsExample lints one example with rule as the only active rule and returns what it reports. -// Every file of the example is visible to rules that read sibling files; the one named after the -// rule's file type is the one linted. -func lintDocsExample(t *testing.T, rule *linter.Rule, page docsPage, files []docsFile) []linter.Issue { - t.Helper() - fileName := docsFileNames[rule.FileTypes[0]] fsys := fstest.MapFS{} var source string var found bool - for _, f := range files { - fsys[path.Clean(f.name)] = &fstest.MapFile{Data: []byte(f.source)} - if f.name == fileName { - source, found = f.source, true + for _, f := range snip.Files { + mode := f.Mode + if mode == 0 { + mode = 0o644 + } + fsys[f.Path] = &fstest.MapFile{Data: []byte(f.Content), Mode: mode} + if f.Path == fileName { + source, found = f.Content, true } } if !found { - t.Fatalf("example has no %s block to lint, got %d file(s)", fileName, len(files)) + t.Fatalf("example has no %s to lint, got %d file(s)", fileName, len(snip.Files)) } doc, err := linter.ParseDocument([]byte(source)) @@ -352,27 +133,8 @@ func lintDocsExample(t *testing.T, rule *linter.Rule, page docsPage, files []doc } l := linter.New() - l.RegisterRule(rule, linter.SeverityError) - return l.LintDocument(fileName, rule.FileTypes[0], doc, linter.Dir{ - FS: fsys, - Name: page.frontMatter["example_dir"], - }) -} - -func platformNames(platforms []linter.Platform) []string { - names := make([]string, len(platforms)) - for i, p := range platforms { - names[i] = p.String() - } - return names -} - -func fileTypeNames(fileTypes []linter.FileType) []string { - names := make([]string, len(fileTypes)) - for i, ft := range fileTypes { - names[i] = string(ft) - } - return names + l.RegisterRule(r, linter.SeverityError) + return l.LintDocument(fileName, r.FileTypes[0], doc, linter.Dir{FS: fsys, Name: snip.DirName}) } // duplicateIndex returns the index of the first element of refs that appears earlier in it, or -1 diff --git a/rules/feature_install_script_not_executable.go b/rules/feature_install_script_not_executable.go index 9ba8c4b..4e1d4ff 100644 --- a/rules/feature_install_script_not_executable.go +++ b/rules/feature_install_script_not_executable.go @@ -14,12 +14,47 @@ import ( var FeatureInstallScriptNotExecutable = &linter.Rule{ ID: "feature-install-script-not-executable", Description: "disallow a Feature's `install.sh` that lacks executable permission bits", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature}, - Paths: []string{""}, - Check: checkFeatureInstallScriptNotExecutable, + LongDescription: `The specification has the installing tool invoke "install.sh" directly rather than through a shell, so +that the script's own shebang selects the interpreter. That requires the execute bit: without it the +Feature fails to install when a container is built. Run "chmod +x install.sh" and commit the mode change.`, + References: []string{ + `https://containers.dev/implementors/features/#invoking-installsh`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer-feature.json", Content: featureInstallScriptExampleFeature}, + {Path: installScriptName, Content: featureInstallScriptExampleScript, Mode: 0o644}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer-feature.json", Content: featureInstallScriptExampleFeature}, + {Path: installScriptName, Content: featureInstallScriptExampleScript, Mode: 0o755}, + }, + }, + Note: "Git records the executable bit, so committing the mode change is what makes\n" + + "the fix stick. On Windows, where the filesystem has no executable bit, set it\n" + + "in the index directly: `git update-index --chmod=+x install.sh`.", + }, + Check: checkFeatureInstallScriptNotExecutable, } +const featureInstallScriptExampleFeature = `{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +` + +const featureInstallScriptExampleScript = `#!/usr/bin/env bash +set -e +apt-get update && apt-get install -y nodejs +` + func checkFeatureInstallScriptNotExecutable(ctx *linter.Context, node *linter.Node) []linter.Finding { // Windows working trees carry no executable bits (git does not set them there), so the check // would report every install.sh as non-executable. Skip it rather than emit false positives. diff --git a/rules/id_dir_mismatch.go b/rules/id_dir_mismatch.go index 9256d12..4593c78 100644 --- a/rules/id_dir_mismatch.go +++ b/rules/id_dir_mismatch.go @@ -12,10 +12,44 @@ import ( var IDDirMismatch = &linter.Rule{ ID: "id-dir-mismatch", Description: `disallow a Feature's or Template's "id" that does not match the name of its containing directory`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{"/id"}, - Check: checkIDDirMismatch, + LongDescription: `Both specifications require the "id" to match the name of the directory holding the metadata file, since +that directory name is what packaging and distribution address the artifact by. When the two disagree the +published reference does not resolve to what the directory contains; rename the directory or the "id" so +they agree.`, + References: []string{ + `https://containers.dev/implementors/features/#devcontainer-featurejson-properties`, + `https://containers.dev/implementors/templates/#devcontainer-templatejson-properties`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{"/id"}, + Example: linter.Example{ + Bad: linter.Snippet{ + DirName: "node", + Files: []linter.ExampleFile{ + {Path: `devcontainer-feature.json`, Content: `// src/node/devcontainer-feature.json +{ + "id": "nodejs", + "version": "1.0.0", + "name": "Node.js" +} +`}, + }, + }, + Good: linter.Snippet{ + DirName: "node", + Files: []linter.ExampleFile{ + {Path: `devcontainer-feature.json`, Content: `// src/node/devcontainer-feature.json +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +`}, + }, + }, + }, + Check: checkIDDirMismatch, } func checkIDDirMismatch(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/invalid_semver.go b/rules/invalid_semver.go index 424cd6e..552753c 100644 --- a/rules/invalid_semver.go +++ b/rules/invalid_semver.go @@ -13,10 +13,39 @@ import ( var InvalidSemver = &linter.Rule{ ID: "invalid-semver", Description: `disallow a Feature's or Template's "version" that is not a valid semantic version`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{"/version"}, - Check: checkInvalidSemver, + LongDescription: `Publishing a Feature or Template pushes it under tags derived from the "version" components: the full +version, "major.minor", and "major", so consumers can pin as loosely or as tightly as they want. A value +that is not valid semver has no such components, leaving nothing to derive those tags from.`, + References: []string{ + `https://containers.dev/implementors/features-distribution/#versioning`, + `https://semver.org/`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{"/version"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer-feature.json`, Content: `{ + "id": "node", + "version": "1.0", + "name": "Node.js" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer-feature.json`, Content: `{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +`}, + }, + }, + }, + Check: checkInvalidSemver, } // semverPattern is the official semantic version regular expression published at diff --git a/rules/missing_build_dockerfile.go b/rules/missing_build_dockerfile.go index 452879e..055c060 100644 --- a/rules/missing_build_dockerfile.go +++ b/rules/missing_build_dockerfile.go @@ -10,10 +10,42 @@ import ( var MissingBuildDockerfile = &linter.Rule{ ID: "missing-build-dockerfile", Description: `disallow a devcontainer.json "build" object that is missing "dockerfile"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/build"}, - Check: checkMissingBuildDockerfile, + LongDescription: `"build.dockerfile" is the only required member of "build": it locates, relative to the devcontainer.json, +the Dockerfile the image is built from. The other members ("context", "args", "target", ...) only shape a +build that "dockerfile" defines, so without it there is nothing to build.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties`, + `https://containers.dev/implementors/spec/#dockerfile-based`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/build"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "build": { + "context": ".." + } +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + } +} +`}, + }, + }, + }, + Check: checkMissingBuildDockerfile, } func checkMissingBuildDockerfile(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_compose_service.go b/rules/missing_compose_service.go index 6bad04f..a654a15 100644 --- a/rules/missing_compose_service.go +++ b/rules/missing_compose_service.go @@ -10,10 +10,40 @@ import ( var MissingComposeService = &linter.Rule{ ID: "missing-compose-service", Description: `disallow a devcontainer.json that sets "dockerComposeFile" without "service"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingComposeService, + LongDescription: `A Compose project usually defines several services, so naming the Compose file does not say which +container the tooling should attach to. The specification requires "service" to name that main container: +it is the one lifecycle scripts run in and the one editors connect to.`, + References: []string{ + `https://containers.dev/implementors/spec/#docker-compose-based`, + `https://containers.dev/implementors/json_reference/#docker-compose-specific-properties`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "dockerComposeFile": "docker-compose.yml", + "workspaceFolder": "/workspace" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace" +} +`}, + }, + }, + }, + Check: checkMissingComposeService, } func checkMissingComposeService(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_container_def.go b/rules/missing_container_def.go index 2f432e8..8c26e27 100644 --- a/rules/missing_container_def.go +++ b/rules/missing_container_def.go @@ -10,10 +10,38 @@ import ( var MissingContainerDef = &linter.Rule{ ID: "missing-container-def", Description: `disallow a devcontainer.json that defines none of "image", "build", or "dockerComposeFile"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingContainerDef, + LongDescription: `Every dev container is created from exactly one of "image", "build", or "dockerComposeFile", and each of +the three is required in its own scenario. A configuration that sets none of them describes no container +at all, so no tool can create one from it.`, + References: []string{ + `https://containers.dev/implementors/spec/#orchestration-options`, + `https://containers.dev/implementors/json_reference/#scenario-specific-properties`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "forwardPorts": [3000] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "name": "my project", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [3000] +} +`}, + }, + }, + }, + Check: checkMissingContainerDef, } func checkMissingContainerDef(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_feature_install_script.go b/rules/missing_feature_install_script.go index 0b16cd3..36d708a 100644 --- a/rules/missing_feature_install_script.go +++ b/rules/missing_feature_install_script.go @@ -16,10 +16,32 @@ const installScriptName = "install.sh" var MissingFeatureInstallScript = &linter.Rule{ ID: "missing-feature-install-script", Description: "disallow a Feature directory without the required `install.sh` install script", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature}, - Paths: []string{""}, - Check: checkMissingFeatureInstallScript, + LongDescription: `A Feature is distributed as its metadata file plus the "install.sh" the tooling runs inside the container, +which is where the Feature does all of its work. A directory without one publishes a Feature that +installs nothing, and the omission only surfaces when someone builds a container with it.`, + References: []string{ + `https://containers.dev/implementors/features/#folder-structure`, + `https://containers.dev/implementors/features/#invoking-installsh`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer-feature.json", Content: featureInstallScriptExampleFeature}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: "devcontainer-feature.json", Content: featureInstallScriptExampleFeature}, + {Path: installScriptName, Content: featureInstallScriptExampleScript, Mode: 0o755}, + }, + }, + Note: "The name is fixed: the tooling runs `install.sh` and nothing else, so an\n" + + "install script under any other name is never executed.", + }, + Check: checkMissingFeatureInstallScript, } func checkMissingFeatureInstallScript(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_required_props.go b/rules/missing_required_props.go index eeebe46..bab6e96 100644 --- a/rules/missing_required_props.go +++ b/rules/missing_required_props.go @@ -12,10 +12,38 @@ import ( var MissingRequiredProps = &linter.Rule{ ID: "missing-required-props", Description: `disallow a Feature's or Template's metadata that is missing a required property ("id", "version", or "name")`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Feature, linter.Template}, - Paths: []string{""}, - Check: checkMissingRequiredProps, + LongDescription: `"id", "version", and "name" are the only properties either specification requires: the "id" addresses the +artifact, the "version" is what consumers pin to, and the "name" is what a user recognizes it by in a +list. Metadata missing any of them cannot be published as a usable Feature or Template.`, + References: []string{ + `https://containers.dev/implementors/features/#devcontainer-featurejson-properties`, + `https://containers.dev/implementors/templates/#devcontainer-templatejson-properties`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Feature, linter.Template}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer-feature.json`, Content: `{ + "id": "node", + "version": "1.0.0" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer-feature.json`, Content: `{ + "id": "node", + "version": "1.0.0", + "name": "Node.js" +} +`}, + }, + }, + }, + Check: checkMissingRequiredProps, } func checkMissingRequiredProps(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/missing_workspace_mount_folder.go b/rules/missing_workspace_mount_folder.go index c59d90e..cd826a2 100644 --- a/rules/missing_workspace_mount_folder.go +++ b/rules/missing_workspace_mount_folder.go @@ -13,10 +13,39 @@ import ( var MissingWorkspaceMountFolder = &linter.Rule{ ID: "missing-workspace-mount-folder", Description: `disallow a devcontainer.json using "image" or "build" that sets only one of "workspaceMount" or "workspaceFolder"`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkMissingWorkspaceMountFolder, + LongDescription: `The two properties describe opposite ends of the same override: "workspaceMount" says where the source +code is mounted, "workspaceFolder" says which path inside the container the tooling opens. The reference +documents each as requiring the other, because setting one alone either mounts the source somewhere +nothing opens, or opens a path nothing is mounted at.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties`, + `https://containers.dev/implementors/spec/#workspacefolder-and-workspacemount`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "workspaceMount": "source=${localWorkspaceFolder},target=/srv/app,type=bind" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "workspaceMount": "source=${localWorkspaceFolder},target=/srv/app,type=bind", + "workspaceFolder": "/srv/app" +} +`}, + }, + }, + }, + Check: checkMissingWorkspaceMountFolder, } func checkMissingWorkspaceMountFolder(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_app_port.go b/rules/no_app_port.go index dc9bbd5..a71946b 100644 --- a/rules/no_app_port.go +++ b/rules/no_app_port.go @@ -8,10 +8,38 @@ import "github.com/bare-devcontainer/decolint/linter" var NoAppPort = &linter.Rule{ ID: "no-app-port", Description: `disallow the legacy "appPort" property in favor of "forwardPorts"`, - Category: linter.CategoryStyle, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/appPort"}, - Check: checkNoAppPort, + LongDescription: `"appPort" publishes the port the way Docker does: it is fixed when the container is created, and the +application has to listen on all interfaces rather than just "localhost" to be reachable. A forwarded +port instead looks like "localhost" to the application and can be changed without recreating the +container, which is why the reference recommends "forwardPorts" in most cases.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties`, + `https://containers.dev/implementors/json_reference/#publishing-vs-forwarding-ports`, + }, + Category: linter.CategoryStyle, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/appPort"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "appPort": [3000] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [3000] +} +`}, + }, + }, + }, + Check: checkNoAppPort, } func checkNoAppPort(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_bind_mount.go b/rules/no_bind_mount.go index f087d4d..ecfab93 100644 --- a/rules/no_bind_mount.go +++ b/rules/no_bind_mount.go @@ -10,11 +10,51 @@ import ( var NoBindMount = &linter.Rule{ ID: "no-bind-mount", Description: `disallow "bind" type entries in "mounts", which GitHub Codespaces silently ignores except for the Docker socket`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformCodespaces}, - Paths: []string{"/mounts/*"}, - Check: checkNoBindMount, + LongDescription: `A codespace runs on a machine in the cloud, where the host path a bind mount points at does not exist, so +Codespaces documents that it ignores "bind" mounts apart from the Docker socket. The mount is dropped +without an error and the container starts missing the data it expects. Volume mounts are honored, so use +"type=volume" for anything that only has to persist across rebuilds.`, + References: []string{ + `https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#github-codespaces`, + `https://containers.dev/implementors/spec/#mounts`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Paths: []string{"/mounts/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "mounts": [ + { + "source": "${localWorkspaceFolder}/.cache", + "target": "/home/vscode/.cache", + "type": "bind" + } + ] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "mounts": [ + { + "source": "devcontainer-cache", + "target": "/home/vscode/.cache", + "type": "volume" + } + ] +} +`}, + }, + }, + }, + Check: checkNoBindMount, } func checkNoBindMount(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_cap_add_all.go b/rules/no_cap_add_all.go index c3505da..68ca18a 100644 --- a/rules/no_cap_add_all.go +++ b/rules/no_cap_add_all.go @@ -12,10 +12,38 @@ import ( var NoCapAddAll = &linter.Rule{ ID: "no-cap-add-all", Description: `disallow granting all Linux capabilities via an "ALL" entry in the "capAdd" property, or a "--cap-add=ALL" entry in a devcontainer.json's "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/capAdd/*", "/runArgs"}, - Check: checkNoCapAddAll, + LongDescription: `Linux capabilities split root's powers into units a container can be granted individually, and the runtime +withholds the dangerous ones by default. "ALL" hands them all over, including capabilities such as +"SYS_ADMIN" and "SYS_MODULE" that let a process reconfigure the host kernel and escape the container. +"capAdd" exists to name the one or two a workload actually needs, e.g. "SYS_PTRACE" for a debugger.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.docker.com/engine/security/#linux-kernel-capabilities`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/capAdd/*", "/runArgs"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["ALL"] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +`}, + }, + }, + }, + Check: checkNoCapAddAll, } func checkNoCapAddAll(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_docker_socket_mount.go b/rules/no_docker_socket_mount.go index df903eb..0eaef14 100644 --- a/rules/no_docker_socket_mount.go +++ b/rules/no_docker_socket_mount.go @@ -14,10 +14,47 @@ import ( var NoDockerSocketMount = &linter.Rule{ ID: "no-docker-socket-mount", Description: `disallow bind-mounting the host's Docker socket via a devcontainer.json's "mounts" or "runArgs", which grants the container root-equivalent control over the host`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/mounts/*", "/runArgs/*"}, - Check: checkNoDockerSocketMount, + LongDescription: `The Docker socket is the daemon's full API, and the daemon runs as root on the host. Anything that can +reach the socket can start a container that mounts the host's filesystem, so mounting it into the dev +container hands root-equivalent control of the host to every process inside — including code the +project's own build fetches. When the container genuinely needs Docker, a Docker-in-Docker Feature or a +rootless daemon keeps that access inside the container.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.docker.com/engine/security/#docker-daemon-attack-surface`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/mounts/*", "/runArgs/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "mounts": [ + { + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "type": "bind" + } + ] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2.13.0": {} + } +} +`}, + }, + }, + }, + Check: checkNoDockerSocketMount, } func checkNoDockerSocketMount(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_host_port_format.go b/rules/no_host_port_format.go index 31fd1d6..9af3a6f 100644 --- a/rules/no_host_port_format.go +++ b/rules/no_host_port_format.go @@ -15,11 +15,39 @@ import ( var NoHostPortFormat = &linter.Rule{ ID: "no-host-port-format", Description: `disallow "host:port" entries in "forwardPorts" and "portsAttributes", which GitHub Codespaces does not support`, - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformCodespaces}, - Paths: []string{"/forwardPorts/*", "/portsAttributes/*"}, - Check: checkNoHostPortFormat, + LongDescription: `The "host:port" form forwards a port from another container in a Docker Compose project (e.g. "db:5432") +rather than from the primary one. Codespaces documents that it does not support that variation of either +property, so the entry is ignored there and the port is not forwarded. A bare port number, which refers +to the primary container, works everywhere.`, + References: []string{ + `https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#github-codespaces`, + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformCodespaces}, + Paths: []string{"/forwardPorts/*", "/portsAttributes/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": ["db:5432"] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [5432] +} +`}, + }, + }, + }, + Check: checkNoHostPortFormat, } func checkNoHostPortFormat(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_image_latest.go b/rules/no_image_latest.go index 5df6680..3d6a9ec 100644 --- a/rules/no_image_latest.go +++ b/rules/no_image_latest.go @@ -13,10 +13,35 @@ import ( var NoImageLatest = &linter.Rule{ ID: "no-image-latest", Description: `disallow container images without an explicit tag or with the "latest" tag`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, - Check: checkNoImageLatest, + 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.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties`, + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/image"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:latest" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" +} +`}, + }, + }, + }, + Check: checkNoImageLatest, } func checkNoImageLatest(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_privileged_container.go b/rules/no_privileged_container.go index fb9df83..a27c244 100644 --- a/rules/no_privileged_container.go +++ b/rules/no_privileged_container.go @@ -12,10 +12,43 @@ import ( var NoPrivilegedContainer = &linter.Rule{ ID: "no-privileged-container", Description: `disallow running the container in privileged mode via the "privileged" property or a "--privileged" entry in "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/privileged", "/runArgs/*"}, - Check: checkNoPrivilegedContainer, + LongDescription: `A privileged container gets every Linux capability, unconfined seccomp and LSM profiles, and access to all +host devices. That removes essentially every boundary between the container and the host, so any code +running in it — including a compromised dependency pulled in by the project's own build — can take over +the machine. Docker-in-Docker is the usual reason it is set; a Feature that provides it, or the specific +capabilities and devices the workload needs, is a far narrower grant.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.docker.com/engine/security/#docker-daemon-attack-surface`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/privileged", "/runArgs/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "privileged": true +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +`}, + }, + }, + Note: `The good example grants only the capability a debugger needs. Reach for the +narrowest grant that works: ` + "`" + `capAdd` + "`" + ` for a capability, ` + "`" + `--device` + "`" + ` in ` + "`" + `runArgs` + "`" + ` +for a device, and the docker-in-docker Feature rather than privileged mode for +nested containers.`, + }, + Check: checkNoPrivilegedContainer, } func checkNoPrivilegedContainer(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_seccomp_override.go b/rules/no_seccomp_override.go index 1e7fd00..fe27bb1 100644 --- a/rules/no_seccomp_override.go +++ b/rules/no_seccomp_override.go @@ -16,10 +16,41 @@ import ( var NoSeccompOverride = &linter.Rule{ ID: "no-seccomp-override", Description: `disallow overriding the container runtime's default seccomp profile via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=..." entry in a devcontainer.json's "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs/*"}, - Check: checkNoSeccompOverride, + LongDescription: `The runtime's default seccomp profile blocks the syscalls containers do not need, several of which have +featured in container escapes. Pointing "seccomp" at a profile of your own replaces that default +wholesale, and a hand-written profile is rarely reviewed as carefully or updated as the kernel gains new +syscalls. Keep the default unless the workload provably needs more, and review the replacement if it +does.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.docker.com/engine/security/seccomp/`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/securityOpt/*", "/runArgs/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "securityOpt": ["seccomp=./seccomp.json"] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +`}, + }, + }, + Note: `Leaving ` + "`" + `securityOpt` + "`" + ` unset keeps the runtime's default seccomp profile, +which already allows what a development container normally does.`, + }, + Check: checkNoSeccompOverride, } func checkNoSeccompOverride(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/no_seccomp_unconfined.go b/rules/no_seccomp_unconfined.go index ddc9941..7696c96 100644 --- a/rules/no_seccomp_unconfined.go +++ b/rules/no_seccomp_unconfined.go @@ -12,10 +12,38 @@ import ( var NoSeccompUnconfined = &linter.Rule{ ID: "no-seccomp-unconfined", Description: `disallow disabling seccomp confinement via a devcontainer.json's or Feature's "securityOpt" property, or a "--security-opt seccomp=unconfined" entry in a devcontainer.json's "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs"}, - Check: checkNoSeccompUnconfined, + LongDescription: `"seccomp=unconfined" turns off syscall filtering entirely, exposing the whole kernel API — including the +calls the default profile blocks precisely because they have been used to break out of containers. The +setting is most often copied from debugger instructions, where granting the "SYS_PTRACE" capability is +enough on current runtimes.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.docker.com/engine/security/seccomp/`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, + Paths: []string{"/securityOpt/*", "/runArgs"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "securityOpt": ["seccomp=unconfined"] +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "capAdd": ["SYS_PTRACE"] +} +`}, + }, + }, + }, + Check: checkNoSeccompUnconfined, } func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_extension_version.go b/rules/pin_extension_version.go index 91c80db..57624d0 100644 --- a/rules/pin_extension_version.go +++ b/rules/pin_extension_version.go @@ -15,11 +15,47 @@ import ( var PinExtensionVersion = &linter.Rule{ ID: "pin-extension-version", Description: `disallow a "customizations.vscode.extensions" entry without an explicit pinned version`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Platforms: []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, - Paths: []string{"/customizations/vscode/extensions/*"}, - Check: checkPinExtensionVersion, + LongDescription: `An extension ID on its own installs whatever the marketplace publishes at the moment the container is +created, so two developers on the same devcontainer.json can end up with different formatters, linters, or +language server versions — and an extension update can change the environment without any commit. +Appending a version ("publisher.name@1.2.3") makes the editor tooling as pinned as the rest of the image.`, + References: []string{ + `https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md#visual-studio-code`, + `https://code.visualstudio.com/docs/configure/extensions/extension-marketplace`, + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Platforms: []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, + Paths: []string{"/customizations/vscode/extensions/*"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": ["golang.go"] + } + } +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": ["golang.go@0.50.0"] + } + } +} +`}, + }, + }, + }, + Check: checkPinExtensionVersion, } func checkPinExtensionVersion(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_feature_version.go b/rules/pin_feature_version.go index 6535582..49bd67a 100644 --- a/rules/pin_feature_version.go +++ b/rules/pin_feature_version.go @@ -16,10 +16,42 @@ import ( var PinFeatureVersion = &linter.Rule{ ID: "pin-feature-version", Description: `disallow a Feature reference without an explicit version or with the "latest" version`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/features"}, - Check: checkPinFeatureVersion, + LongDescription: `A Feature reference with no version resolves to "latest", so the container installs whatever the Feature's +author published most recently — the tooling it sets up can change under the project without the +devcontainer.json changing at all. Features are published under their full version as well as +"major.minor" and "major" tags, so a reference can be pinned as tightly as the project wants.`, + References: []string{ + `https://containers.dev/implementors/features-distribution/#versioning`, + `https://containers.dev/implementors/features/#referencing-a-feature`, + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/features"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/go": {} + } +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/go:1.3.2": {} + } +} +`}, + }, + }, + }, + Check: checkPinFeatureVersion, } func checkPinFeatureVersion(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/pin_image_digest.go b/rules/pin_image_digest.go index 434f06d..a8fcb09 100644 --- a/rules/pin_image_digest.go +++ b/rules/pin_image_digest.go @@ -21,10 +21,36 @@ var digestSuffix = regexp.MustCompile(`@[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9 var PinImageDigest = &linter.Rule{ ID: "pin-image-digest", Description: `disallow an "image" property that does not pin the image by content digest (e.g. "image@sha256:...")`, - Category: linter.CategoryReproducibility, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, - Check: checkPinImageDigest, + LongDescription: `A tag is a mutable pointer: the publisher can move even a fully specified one to different bits, and a +registry can serve a different image for the same tag on a different day. A digest names the content +itself, so "image@sha256:..." always resolves to the exact image the project was tested with, and the +client verifies what it pulled against it.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#image-or-dockerfile-specific-properties`, + `https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests`, + }, + Category: linter.CategoryReproducibility, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{"/image"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:2a1d1e1a4b0c3f8e5c8a1e0a6d3b7c9f4e2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b" +} +`}, + }, + }, + }, + Check: checkPinImageDigest, } func checkPinImageDigest(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_cap_drop_all.go b/rules/require_cap_drop_all.go index 5bd459c..e698732 100644 --- a/rules/require_cap_drop_all.go +++ b/rules/require_cap_drop_all.go @@ -12,10 +12,42 @@ import ( var RequireCapDropAll = &linter.Rule{ ID: "require-cap-drop-all", Description: `require a "--cap-drop=ALL" entry in a devcontainer.json's "runArgs", dropping every Linux capability`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireCapDropAll, + LongDescription: `Container runtimes grant a default set of capabilities that a dev container almost never uses: raw network +access, changing file ownership, or binding privileged ports. Dropping all of them and adding back only +what the workload needs ("capAdd") means a process that is compromised inherits no privilege the project +never asked for.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.docker.com/engine/security/#linux-kernel-capabilities`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "runArgs": ["--cap-drop=ALL"], + "capAdd": ["CHOWN", "SETUID", "SETGID"] +} +`}, + }, + }, + Note: "`" + `runArgs` + "`" + ` is the only place this can be expressed: devcontainer.json has a +` + "`" + `capAdd` + "`" + ` property but no ` + "`" + `capDrop` + "`" + ` one, so dropping capabilities means passing +the flag to the container runtime. Add back through ` + "`" + `capAdd` + "`" + ` whatever the +workload actually needs.`, + }, + Check: checkRequireCapDropAll, } func checkRequireCapDropAll(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_no_new_privileges.go b/rules/require_no_new_privileges.go index ce3c718..fc65ccc 100644 --- a/rules/require_no_new_privileges.go +++ b/rules/require_no_new_privileges.go @@ -13,10 +13,37 @@ import ( var RequireNoNewPrivileges = &linter.Rule{ ID: "require-no-new-privileges", Description: `require "no-new-privileges" to be set via a devcontainer.json's "securityOpt" property, or a "--security-opt no-new-privileges..." entry in "runArgs"`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireNoNewPrivileges, + LongDescription: `Without this option a process in the container can still gain privileges it was not started with, by +executing a setuid binary — which undercuts the point of running as a non-root user. Setting it raises the +kernel's "no_new_privs" bit, which every child process inherits and none can clear, so the container's +privileges can only ever shrink.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#general-devcontainerjson-properties`, + `https://docs.kernel.org/userspace-api/no_new_privs.html`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "securityOpt": ["no-new-privileges"] +} +`}, + }, + }, + }, + Check: checkRequireNoNewPrivileges, } func checkRequireNoNewPrivileges(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/require_non_root.go b/rules/require_non_root.go index 02aa4b5..620a3ee 100644 --- a/rules/require_non_root.go +++ b/rules/require_non_root.go @@ -16,10 +16,41 @@ import ( var RequireNonRoot = &linter.Rule{ ID: "require-non-root", Description: `require "remoteUser" or, if unset, "containerUser" to be set to a non-root user`, - Category: linter.CategorySecurity, - FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{""}, - Check: checkRequireNonRoot, + LongDescription: `"remoteUser" defaults to whatever user the container runs as, which for most images is root. Everything +the developer's session drives then runs as root: lifecycle scripts, terminals, and the language servers +and build tools the editor starts, so a compromised dependency runs with full control of the container. +Naming an unprivileged user — as the specification's own images do — costs nothing and contains it.`, + References: []string{ + `https://containers.dev/implementors/json_reference/#remoteuser`, + `https://containers.dev/implementors/spec/#users`, + }, + Category: linter.CategorySecurity, + FileTypes: []linter.FileType{linter.Devcontainer}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "root" +} +`}, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + {Path: `devcontainer.json`, Content: `{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "vscode" +} +`}, + }, + }, + Note: "`" + `remoteUser` + "`" + ` is what lifecycle scripts and the editor's remote session run as, +and it wins over ` + "`" + `containerUser` + "`" + `; a rule that finds neither reports the +container's default, which is ` + "`" + `root` + "`" + ` for most images.`, + }, + Check: checkRequireNonRoot, } func checkRequireNonRoot(_ *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/undefined_template_option.go b/rules/undefined_template_option.go index 2b76fc1..8aed4aa 100644 --- a/rules/undefined_template_option.go +++ b/rules/undefined_template_option.go @@ -16,10 +16,74 @@ import ( var UndefinedTemplateOption = &linter.Rule{ ID: "undefined-template-option", Description: "disallow a `${templateOption:...}` reference to an option not declared in devcontainer-template.json", - Category: linter.CategoryCorrectness, - FileTypes: []linter.FileType{linter.Template}, - Paths: []string{""}, - Check: checkUndefinedTemplateOption, + LongDescription: `Applying a Template replaces each "${templateOption:name}" with the value the user chose for the option of +that name. A reference to an option that "options" does not declare is never prompted for, and the +reference implementation substitutes the empty string for it, so a typo silently produces an empty value +in the applied files instead of an error.`, + References: []string{ + `https://containers.dev/implementors/templates/#the-options-property`, + `https://github.com/devcontainers/cli`, + }, + Category: linter.CategoryCorrectness, + FileTypes: []linter.FileType{linter.Template}, + Paths: []string{""}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + { + Path: `devcontainer-template.json`, + Content: `{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +`, + }, + { + Path: `.devcontainer/devcontainer.json`, + Content: `{ + "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:variant}" +} +`, + }, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + { + Path: `devcontainer-template.json`, + Content: `{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +`, + }, + { + Path: `.devcontainer/devcontainer.json`, + Content: `{ + "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:imageVariant}" +} +`, + }, + }, + }, + }, + Check: checkUndefinedTemplateOption, } func checkUndefinedTemplateOption(ctx *linter.Context, node *linter.Node) []linter.Finding { diff --git a/rules/unused_template_option.go b/rules/unused_template_option.go index 73c0418..3cbc2f0 100644 --- a/rules/unused_template_option.go +++ b/rules/unused_template_option.go @@ -13,10 +13,73 @@ import ( var UnusedTemplateOption = &linter.Rule{ ID: "unused-template-option", Description: "disallow a Template option that no file in the Template references", - Category: linter.CategoryStyle, - FileTypes: []linter.FileType{linter.Template}, - Paths: []string{"/options"}, - Check: checkUnusedTemplateOption, + LongDescription: `An option only takes effect where a file substitutes it as "${templateOption:name}". One that nothing +references is still presented to the user when the Template is applied, so it asks a question whose +answer changes nothing — usually a leftover from a removed file or a renamed reference.`, + References: []string{ + `https://containers.dev/implementors/templates/#the-options-property`, + `https://containers.dev/implementors/templates/#option-resolution`, + }, + Category: linter.CategoryStyle, + FileTypes: []linter.FileType{linter.Template}, + Paths: []string{"/options"}, + Example: linter.Example{ + Bad: linter.Snippet{ + Files: []linter.ExampleFile{ + { + Path: `devcontainer-template.json`, + Content: `{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +`, + }, + { + Path: `.devcontainer/devcontainer.json`, + Content: `{ + "image": "mcr.microsoft.com/devcontainers/dotnet:9.0" +} +`, + }, + }, + }, + Good: linter.Snippet{ + Files: []linter.ExampleFile{ + { + Path: `devcontainer-template.json`, + Content: `{ + "id": "dotnet", + "version": "1.0.0", + "name": "C# (.NET)", + "options": { + "imageVariant": { + "type": "string", + "proposals": ["8.0", "9.0"], + "default": "9.0" + } + } +} +`, + }, + { + Path: `.devcontainer/devcontainer.json`, + Content: `{ + "image": "mcr.microsoft.com/devcontainers/dotnet:${templateOption:imageVariant}" +} +`, + }, + }, + }, + }, + Check: checkUnusedTemplateOption, } func checkUnusedTemplateOption(ctx *linter.Context, node *linter.Node) []linter.Finding { From ce3f5fb4194268cec4632e3523e8dd89d3b96fa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 11:03:22 +0000 Subject: [PATCH 12/24] fix(lint): satisfy golangci-lint in cmd/docgen modernize:stringsseq (scanHeadings), revive:empty-block (atxHeading's character-counting loop, rewritten as a while-style loop with the increment in its body), staticcheck:QF1001 (De Morgan's law on the heading-order check), and wrapcheck (updateReadmeRulesTable's final os.WriteFile). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- cmd/docgen/readme.go | 2 +- cmd/docgen/rules.go | 5 ++++- cmd/docgen/slug.go | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/docgen/readme.go b/cmd/docgen/readme.go index db0fabe..c29c359 100644 --- a/cmd/docgen/readme.go +++ b/cmd/docgen/readme.go @@ -66,7 +66,7 @@ func splitReadme(src string) (map[string]string, error) { return nil, fmt.Errorf("could not find all expected section headings (why=%d try=%d linting=%d reference=%d contributing=%d)", whyIdx, tryIdx, lintingIdx, refIdx, contribIdx) } - if !(whyIdx < tryIdx && tryIdx < lintingIdx && lintingIdx < refIdx && refIdx < contribIdx) { + if whyIdx >= tryIdx || tryIdx >= lintingIdx || lintingIdx >= refIdx || refIdx >= contribIdx { return nil, fmt.Errorf("section headings are not in the expected order (why=%d try=%d linting=%d reference=%d contributing=%d)", whyIdx, tryIdx, lintingIdx, refIdx, contribIdx) } diff --git a/cmd/docgen/rules.go b/cmd/docgen/rules.go index d13a292..d355e4a 100644 --- a/cmd/docgen/rules.go +++ b/cmd/docgen/rules.go @@ -141,7 +141,10 @@ func updateReadmeRulesTable(path string) error { if out == string(src) { return nil } - return os.WriteFile(path, []byte(out), 0o644) + if err := os.WriteFile(path, []byte(out), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil } // renderRulesTable renders every built-in rule as a Markdown table row, each ID linking to its diff --git a/cmd/docgen/slug.go b/cmd/docgen/slug.go index 40c9de8..bbfd1bc 100644 --- a/cmd/docgen/slug.go +++ b/cmd/docgen/slug.go @@ -36,7 +36,7 @@ type heading struct { func scanHeadings(body string) []heading { var out []heading fenced := false - for _, line := range strings.Split(body, "\n") { + for line := range strings.SplitSeq(body, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "```") { fenced = !fenced continue @@ -56,7 +56,8 @@ func scanHeadings(body string) []heading { // atxHeading parses line as an ATX heading ("# Title" through "###### Title"), returning its level // and text with any trailing "#"s trimmed. func atxHeading(line string) (level int, text string, ok bool) { - for level = 0; level < len(line) && level < 6 && line[level] == '#'; level++ { + for level < len(line) && level < 6 && line[level] == '#' { + level++ } if level == 0 || level >= len(line) || line[level] != ' ' { return 0, "", false From d69de815c6b815efc3d134d7f5b7b556a84d96b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:28:44 +0000 Subject: [PATCH 13/24] style(site): polish footer, widen content, add TOC to every long page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Footer: centered, a copyright line, and a lighter top rule than the header's so it reads as secondary rather than competing with it. Content column: 46rem was cramped; 58rem via the single --content-width variable both .layout and .layout--with-sidebar already derive from. Getting started and Reference now get a sidebar table of contents the same way Rules pages get rule-nav — baseof.html's sidebar condition is now "rules section OR Params.toc", rendering whichever partial applies. cmd/docgen sets toc: true on both pages instead of Reference alone. tableOfContents.endLevel moves from 2 to 3 so Getting started's h3 step-by-step subsections (### 1. Run it, ...) appear nested under their h2, not just the section headings; Reference has only one h3 today, so this doesn't change look there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- cmd/docgen/readme.go | 8 ++-- cmd/docgen/readme_test.go | 4 +- docs/assets/css/main.css | 65 +++++++++++++++-------------- docs/hugo.toml | 4 +- docs/layouts/baseof.html | 11 ++++- docs/layouts/page.html | 3 -- docs/layouts/partials/page-toc.html | 7 ++++ 7 files changed, 59 insertions(+), 43 deletions(-) create mode 100644 docs/layouts/partials/page-toc.html diff --git a/cmd/docgen/readme.go b/cmd/docgen/readme.go index c29c359..13b5a1e 100644 --- a/cmd/docgen/readme.go +++ b/cmd/docgen/readme.go @@ -92,12 +92,12 @@ func splitReadme(src string) (map[string]string, error) { } } + // Getting started and Reference are both long enough, with enough ## sections, to be worth a + // table of contents; see docs/layouts/baseof.html and page-toc.html. frontMatter := map[string]string{ "_index": "title: decolint\ndescription: " + yamlSingleQuoted(landingDescription), - "getting-started": "title: Getting started\ndescription: " + yamlSingleQuoted(gettingStartedSummary), - // Reference is long enough, and the flattest (mostly ## headings, few subsections), to be - // worth a table of contents; see docs/layouts/page.html. - "reference": "title: Reference\ndescription: " + yamlSingleQuoted(referenceSummary) + "\ntoc: true", + "getting-started": "title: Getting started\ndescription: " + yamlSingleQuoted(gettingStartedSummary) + "\ntoc: true", + "reference": "title: Reference\ndescription: " + yamlSingleQuoted(referenceSummary) + "\ntoc: true", } pages := make(map[string]string, len(bodies)) diff --git a/cmd/docgen/readme_test.go b/cmd/docgen/readme_test.go index 38d378c..1b6cda4 100644 --- a/cmd/docgen/readme_test.go +++ b/cmd/docgen/readme_test.go @@ -78,8 +78,8 @@ func TestSplitReadme(t *testing.T) { } gs := pages["getting-started"] - if !strings.Contains(gs, "title: Getting started") { - t.Errorf("getting-started front matter missing title, got:\n%s", gs) + if !strings.Contains(gs, "title: Getting started") || !strings.Contains(gs, "toc: true") { + t.Errorf("getting-started front matter missing title/toc, got:\n%s", gs) } if strings.Contains(gs, "Reference body") || strings.Contains(gs, "Not part of the site") { t.Errorf("getting-started leaked reference/contributing content, got:\n%s", gs) diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 1eafc6a..eb4c317 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -10,7 +10,7 @@ --border: #d1d9e0; --accent: #0969da; --surface: #f6f8fa; - --content-width: 46rem; + --content-width: 58rem; } @media (prefers-color-scheme: dark) { @@ -119,10 +119,16 @@ th { } .site-footer { - border-top: 1px solid var(--border); + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); color: var(--fg-muted); + font-size: 0.9rem; margin-top: 4rem; padding: 1.5rem; + text-align: center; +} + +.site-footer p { + margin: 0 0 0.35rem; } /* Layout */ @@ -191,7 +197,8 @@ h3 { font-size: 0.85rem; } -.rule-nav { +.rule-nav, +.page-toc { border-right: 1px solid var(--border); font-size: 0.9rem; padding-right: 1.25rem; @@ -201,27 +208,43 @@ h3 { font-weight: 600; } -.rule-nav h2 { - border: 0; +.rule-nav h2, +.page-toc__label { color: var(--fg-muted); font-size: 0.75rem; letter-spacing: 0.04em; margin: 1.4rem 0 0.4rem; - padding: 0; text-transform: uppercase; } -.rule-nav ul { +.rule-nav h2 { + border: 0; + padding: 0; +} + +.page-toc__label { + margin-top: 0; +} + +.rule-nav ul, +.page-toc ul { list-style: none; margin: 0; padding: 0; } -.rule-nav li { +/* Nested (h3-under-h2) entries in the table of contents. */ +.page-toc ul ul { + padding-left: 0.9rem; +} + +.rule-nav li, +.page-toc li { margin: 0.15rem 0; } -.rule-nav a { +.rule-nav a, +.page-toc a { word-break: break-word; } @@ -235,30 +258,10 @@ h3 { grid-template-columns: minmax(0, 1fr); } - .rule-nav { + .rule-nav, + .page-toc { border-bottom: 1px solid var(--border); border-right: 0; padding: 0 0 1rem; } } - -/* Table of contents (long pages, e.g. Reference) */ - -.toc { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - margin: 0 0 2rem; - padding: 0.9rem 1.2rem; -} - -.toc ul { - list-style: none; - margin: 0; - padding-left: 1rem; -} - -.toc > ul { - padding-left: 0; -} diff --git a/docs/hugo.toml b/docs/hugo.toml index 34ad719..1dcdb52 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -52,9 +52,11 @@ disableKinds = ["taxonomy", "term", "RSS"] # Rule pages are written by contributors and rendered as-is; nothing needs raw HTML. unsafe = false +# endLevel = 3 so Getting started's step-by-step h3 subsections ("### 1. Run it", ...) show in its +# sidebar TOC; Reference has only one h3 ("### Rule categories"), so this costs it nothing. [markup.tableOfContents] startLevel = 2 - endLevel = 2 + endLevel = 3 [[menus.main]] name = "Getting started" diff --git a/docs/layouts/baseof.html b/docs/layouts/baseof.html index 99c937b..3441c14 100644 --- a/docs/layouts/baseof.html +++ b/docs/layouts/baseof.html @@ -23,14 +23,21 @@ -
- {{ if eq .Section "rules" }}{{ partial "rule-nav.html" . }}{{ end }} + {{ $isRules := eq .Section "rules" }} + {{ $hasToc := .Params.toc }} +
+ {{ if $isRules }} + {{ partial "rule-nav.html" . }} + {{ else if $hasToc }} + {{ partial "page-toc.html" . }} + {{ end }}
{{ block "main" . }}{{ end }}
diff --git a/docs/layouts/page.html b/docs/layouts/page.html index e9eb49d..a94a294 100644 --- a/docs/layouts/page.html +++ b/docs/layouts/page.html @@ -14,9 +14,6 @@

{{ .Title }}

{{ with .Params.platforms }}{{ delimit . ", " }}{{ else }}all{{ end }}
{{ end }} - {{ if .Params.toc }} - - {{ end }} {{ .Content }} {{ end }} diff --git a/docs/layouts/partials/page-toc.html b/docs/layouts/partials/page-toc.html new file mode 100644 index 0000000..583a56f --- /dev/null +++ b/docs/layouts/partials/page-toc.html @@ -0,0 +1,7 @@ +{{/* The sidebar shown alongside a long page (Getting started, Reference): a table of contents +built from the page's own ## headings, via Params.toc — see cmd/docgen and docs/hugo.toml's +[markup.tableOfContents]. */}} + From e7cca196052d1df15e6de4b19ad4750ec86e4f4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:38:22 +0000 Subject: [PATCH 14/24] fix(site): keep the sidebar visible while scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .rule-nav and .page-toc scrolled away with the page, so a rule sidebar entry or a TOC link past the first screenful became unreachable without scrolling back up. Make them position: sticky, capped to the viewport height with their own scrollbar for a sidebar taller than the screen (most rule-nav's are, listing all 27 rules). Below the 60rem breakpoint, where the sidebar stacks above the content instead of sitting beside it, sticky is turned back off — pinning a tall list to the top of a narrow viewport would just cover the article. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- docs/assets/css/main.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index eb4c317..f138091 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -199,9 +199,14 @@ h3 { .rule-nav, .page-toc { + align-self: start; border-right: 1px solid var(--border); font-size: 0.9rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; padding-right: 1.25rem; + position: sticky; + top: 1.5rem; } .rule-nav__all { @@ -262,6 +267,9 @@ h3 { .page-toc { border-bottom: 1px solid var(--border); border-right: 0; + max-height: none; + overflow-y: visible; padding: 0 0 1rem; + position: static; } } From e9d76cdecd3cea5d518053375ede14ad1bc285ab Mon Sep 17 00:00:00 2001 From: nozaq Date: Sun, 26 Jul 2026 12:49:46 +0000 Subject: [PATCH 15/24] add images --- docs/assets/img/decoling_logo_dark.svg | 17 +++++++++++++++++ docs/assets/img/decolint.svg | 19 +++++++++++++++++++ docs/assets/img/decolint_logo.svg | 17 +++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 docs/assets/img/decoling_logo_dark.svg create mode 100644 docs/assets/img/decolint.svg create mode 100644 docs/assets/img/decolint_logo.svg diff --git a/docs/assets/img/decoling_logo_dark.svg b/docs/assets/img/decoling_logo_dark.svg new file mode 100644 index 0000000..5a3a280 --- /dev/null +++ b/docs/assets/img/decoling_logo_dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + decolint + \ No newline at end of file diff --git a/docs/assets/img/decolint.svg b/docs/assets/img/decolint.svg new file mode 100644 index 0000000..f77b0a4 --- /dev/null +++ b/docs/assets/img/decolint.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/img/decolint_logo.svg b/docs/assets/img/decolint_logo.svg new file mode 100644 index 0000000..6888e51 --- /dev/null +++ b/docs/assets/img/decolint_logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + decolint + \ No newline at end of file From c23a62e39329b16c715def3a22877052ba9749f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:55:10 +0000 Subject: [PATCH 16/24] feat(site): use the project logo for favicon and navbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decolint.svg (the dark-square avatar) becomes the favicon; the navbar title swaps the plain text wordmark for the logo SVG. It ships as two color variants for one background each, so both render as tags and prefers-color-scheme toggles which is visible — the same mechanism main.css already uses for every other color. Referencing them as rather than inlining also sidesteps the two variants sharing the same mask id, which inlining both into one DOM would collide on. Renamed docs/assets/img/decoling_logo_dark.svg to decolint_logo_dark.svg (typo in the filename as provided). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- docs/assets/css/main.css | 29 ++++++++++++++++--- ...g_logo_dark.svg => decolint_logo_dark.svg} | 0 docs/layouts/baseof.html | 12 +++++++- 3 files changed, 36 insertions(+), 5 deletions(-) rename docs/assets/img/{decoling_logo_dark.svg => decolint_logo_dark.svg} (100%) diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index f138091..0d0bf52 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -94,7 +94,7 @@ th { /* Header, footer */ .site-header { - align-items: baseline; + align-items: center; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; @@ -103,9 +103,30 @@ th { } .site-title { - color: var(--fg); - font-size: 1.15rem; - font-weight: 600; + display: inline-flex; +} + +/* The logo's wordmark already renders "decolint", in a color chosen for one background; swap + which variant is visible instead of recoloring, matching how the rest of the site's colors + respond to prefers-color-scheme. */ +.site-logo { + display: block; + height: 28px; + width: auto; +} + +.site-logo--dark { + display: none; +} + +@media (prefers-color-scheme: dark) { + .site-logo--light { + display: none; + } + + .site-logo--dark { + display: block; + } } .site-header nav { diff --git a/docs/assets/img/decoling_logo_dark.svg b/docs/assets/img/decolint_logo_dark.svg similarity index 100% rename from docs/assets/img/decoling_logo_dark.svg rename to docs/assets/img/decolint_logo_dark.svg diff --git a/docs/layouts/baseof.html b/docs/layouts/baseof.html index 3441c14..8036de8 100644 --- a/docs/layouts/baseof.html +++ b/docs/layouts/baseof.html @@ -6,6 +6,7 @@ {{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} — {{ site.Title }}{{ end }} {{ with .OutputFormats.Get "html" }}{{ end }} + {{ with resources.Get "img/decolint.svg" | minify | fingerprint }}{{ end }} {{ range slice "css/main.css" "css/syntax.css" }} {{ with resources.Get . | minify | fingerprint }} @@ -14,7 +15,16 @@ {{ $isRules := eq .Section "rules" }} From 92420a70a8e370367077d107800d5cfb735d29d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:29:18 +0000 Subject: [PATCH 19/24] site: fix invisible punctuation in dark-mode code blocks github-dark omits Chroma token classes (Punctuation, NameAttribute, NameBuiltin, NameBuiltinPseudo) it colors the same as its default text color. The unconditional light-mode rule for those classes was leaking into dark mode instead, rendering near-black text like `{`/`}`/`,`/`:` on the dark background. Emit an inherit fallback for every light-mode token class ahead of the dark style's own declarations, so classes the dark style doesn't redefine fall back to the dark foreground instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- Makefile | 7 +++- docs/assets/css/syntax.css | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 156f3f0..c9b326e 100644 --- a/Makefile +++ b/Makefile @@ -61,11 +61,16 @@ site-content: ## Regenerate the rule pages, README-derived pages, and README rul .PHONY: site-syntax site-syntax: ## Regenerate the syntax highlighting stylesheet @{ \ + light=$$($(HUGO) gen chromastyles --style=github | tail -n +2); \ echo '/* Chroma syntax highlighting token colors.'; \ echo ' Generated by `make site-syntax`; edit that target rather than this file. */'; \ echo; \ - $(HUGO) gen chromastyles --style=github | tail -n +2; \ + echo "$$light"; \ echo '@media (prefers-color-scheme: dark) {'; \ + echo ' /* github-dark omits token classes it colors the same as the default text color,'; \ + echo ' so fall back to inheriting that color instead of the light style'"'"'s, which'; \ + echo ' stays in effect below for every class the dark style does define. */'; \ + echo "$$light" | grep -oE '\.chroma \.[A-Za-z0-9]+' | sort -u | sed 's/.*/ & { color: inherit }/'; \ $(HUGO) gen chromastyles --style=github-dark | tail -n +2 | sed 's/^./ &/'; \ echo '}'; \ } > docs/assets/css/syntax.css diff --git a/docs/assets/css/syntax.css b/docs/assets/css/syntax.css index 949131a..f3045a6 100644 --- a/docs/assets/css/syntax.css +++ b/docs/assets/css/syntax.css @@ -76,6 +76,80 @@ /* GenericUnderline */ .chroma .gl { text-decoration:underline } /* TextWhitespace */ .chroma .w { color:#fff } @media (prefers-color-scheme: dark) { + /* github-dark omits token classes it colors the same as the default text color, + so fall back to inheriting that color instead of the light style's, which + stays in effect below for every class the dark style does define. */ + .chroma .bp { color: inherit } + .chroma .c { color: inherit } + .chroma .c1 { color: inherit } + .chroma .ch { color: inherit } + .chroma .cm { color: inherit } + .chroma .cp { color: inherit } + .chroma .cpf { color: inherit } + .chroma .cs { color: inherit } + .chroma .dl { color: inherit } + .chroma .err { color: inherit } + .chroma .fm { color: inherit } + .chroma .gd { color: inherit } + .chroma .ge { color: inherit } + .chroma .gi { color: inherit } + .chroma .gl { color: inherit } + .chroma .go { color: inherit } + .chroma .hl { color: inherit } + .chroma .il { color: inherit } + .chroma .k { color: inherit } + .chroma .kc { color: inherit } + .chroma .kd { color: inherit } + .chroma .kn { color: inherit } + .chroma .kp { color: inherit } + .chroma .kr { color: inherit } + .chroma .kt { color: inherit } + .chroma .line { color: inherit } + .chroma .ln { color: inherit } + .chroma .lnlinks { color: inherit } + .chroma .lnt { color: inherit } + .chroma .lntable { color: inherit } + .chroma .lntd { color: inherit } + .chroma .m { color: inherit } + .chroma .mb { color: inherit } + .chroma .mf { color: inherit } + .chroma .mh { color: inherit } + .chroma .mi { color: inherit } + .chroma .mo { color: inherit } + .chroma .na { color: inherit } + .chroma .nb { color: inherit } + .chroma .nc { color: inherit } + .chroma .nd { color: inherit } + .chroma .nf { color: inherit } + .chroma .ni { color: inherit } + .chroma .nl { color: inherit } + .chroma .nn { color: inherit } + .chroma .no { color: inherit } + .chroma .nt { color: inherit } + .chroma .nv { color: inherit } + .chroma .nx { color: inherit } + .chroma .o { color: inherit } + .chroma .or { color: inherit } + .chroma .ow { color: inherit } + .chroma .p { color: inherit } + .chroma .s { color: inherit } + .chroma .s1 { color: inherit } + .chroma .s2 { color: inherit } + .chroma .sa { color: inherit } + .chroma .sb { color: inherit } + .chroma .sc { color: inherit } + .chroma .sd { color: inherit } + .chroma .se { color: inherit } + .chroma .sh { color: inherit } + .chroma .si { color: inherit } + .chroma .sr { color: inherit } + .chroma .ss { color: inherit } + .chroma .sx { color: inherit } + .chroma .vc { color: inherit } + .chroma .vg { color: inherit } + .chroma .vi { color: inherit } + .chroma .vm { color: inherit } + .chroma .w { color: inherit } /* Background */ .bg { color:#e6edf3;background-color:#0d1117; } /* PreWrapper */ .chroma { color:#e6edf3;background-color:#0d1117;-webkit-text-size-adjust:none; } From 2ffde1bb432fd8db8e9f40a77809d57a82fdae5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:37:39 +0000 Subject: [PATCH 20/24] site: fix header logo and "All rules" links on the deployed subpath relURL/absURL treat a leading "/" as root-relative and drop the baseURL's own path, so both links pointed at https://bare-devcontainer.github.io/ instead of .../decolint/ on the deployed site (relPermalink-based links, e.g. the main nav, were unaffected). Use site.Home.RelPermalink and the rules section page's RelPermalink instead, consistent with how the rest of the templates resolve URLs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0141gA9jA24NHbAauanrYjhR --- docs/layouts/baseof.html | 2 +- docs/layouts/partials/rule-nav.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/layouts/baseof.html b/docs/layouts/baseof.html index a56d53a..ee31773 100644 --- a/docs/layouts/baseof.html +++ b/docs/layouts/baseof.html @@ -15,7 +15,7 @@