diff --git a/go.mod b/go.mod index f0226884d..bf3ad8332 100644 --- a/go.mod +++ b/go.mod @@ -151,6 +151,7 @@ require ( github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c github.com/u-root/u-root v0.14.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vbatts/tar-split v0.12.1 // indirect diff --git a/go.sum b/go.sum index 9b778fd56..dc33d1f78 100644 --- a/go.sum +++ b/go.sum @@ -360,6 +360,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c h1:HelZ2kAFadG0La9d+4htN4HzQ68Bm2iM9qKMSMES6xg= +github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c/go.mod h1:JlzghshsemAMDGZLytTFY8C1JQxQPhnatWqNwUXjggo= github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 3b123fe74..9f224f6ef 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -60,6 +60,7 @@ func New() *cobra.Command { cmd.AddCommand(keygen()) cmd.AddCommand(licenseCheck()) cmd.AddCommand(lint()) + cmd.AddCommand(lintConfigCmd()) cmd.AddCommand(packageVersion()) cmd.AddCommand(query()) cmd.AddCommand(scan()) diff --git a/pkg/cli/lint_config.go b/pkg/cli/lint_config.go new file mode 100644 index 000000000..7080a8c75 --- /dev/null +++ b/pkg/cli/lint_config.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "errors" + "strings" + + "github.com/spf13/cobra" + + "chainguard.dev/melange/pkg/configlint" +) + +type lintConfigOptions struct { + args []string + list bool + skipRules []string + severity string +} + +func lintConfigCmd() *cobra.Command { + o := &lintConfigOptions{} + cmd := &cobra.Command{ + Use: "lint-config", + DisableAutoGenTag: true, + SilenceUsage: true, + SilenceErrors: true, + Short: "Lint melange configuration files", + RunE: func(cmd *cobra.Command, args []string) error { + o.args = args + return o.lint(cmd.Context()) + }, + } + cmd.Flags().BoolVarP(&o.list, "list", "l", false, "prints all available rules and exits") + cmd.Flags().StringArrayVarP(&o.skipRules, "skip-rule", "", []string{}, "list of rules to skip") + cmd.Flags().StringVarP(&o.severity, "severity", "s", "warning", "minimum severity level to report (error, warning, info)") + return cmd +} + +func (o lintConfigOptions) lint(ctx context.Context) error { + l := configlint.New(o.makeOptions()...) + + if o.list { + l.PrintRules(ctx) + return nil + } + + minSeverity := configlint.SeverityWarning + switch strings.ToLower(o.severity) { + case "error": + minSeverity = configlint.SeverityError + case "info": + minSeverity = configlint.SeverityInfo + } + + result, err := l.Lint(ctx, minSeverity) + if err != nil { + return err + } + if result.HasErrors() { + l.Print(ctx, result) + for _, res := range result { + for _, e := range res.Errors { + if e.Rule.Severity.Value == configlint.SeverityErrorLevel { + return errors.New("linting failed") + } + } + } + } + return nil +} + +func (o lintConfigOptions) makeOptions() []configlint.Option { + if len(o.args) == 0 { + o.args = []string{"."} + } + return []configlint.Option{ + configlint.WithPath(o.args[0]), + configlint.WithSkipRules(o.skipRules), + } +} diff --git a/pkg/configlint/linter.go b/pkg/configlint/linter.go new file mode 100644 index 000000000..c8312630b --- /dev/null +++ b/pkg/configlint/linter.go @@ -0,0 +1,111 @@ +package configlint + +import ( + "context" + "fmt" + "sort" + + "golang.org/x/exp/slices" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/chainguard-dev/clog" +) + +// Linter represents a linter instance. +type Linter struct { + options Options +} + +// New initializes a new instance of Linter. +func New(opts ...Option) *Linter { + o := Options{} + for _, opt := range opts { + opt(&o) + } + return &Linter{options: o} +} + +// Lint evaluates all rules and returns the result. +func (l *Linter) Lint(ctx context.Context, minSeverity Severity) (Result, error) { + log := clog.FromContext(ctx) + rules := AllRules(l) + + namesToPkg, err := ReadAllPackagesFromRepo(ctx, l.options.Path) + if err != nil { + return Result{}, err + } + + // reset host tracking for each run + seenHosts = map[string]bool{} + + results := make(Result, 0) + + sortedNames := make([]string, 0, len(namesToPkg)) + for n := range namesToPkg { + sortedNames = append(sortedNames, n) + } + sort.Strings(sortedNames) + + for _, name := range sortedNames { + failedRules := make(EvalRuleErrors, 0) + for _, rule := range rules { + shouldEvaluate := true + if len(rule.ConditionFuncs) > 0 { + for _, cond := range rule.ConditionFuncs { + if !cond() { + shouldEvaluate = false + break + } + } + } + if !shouldEvaluate { + log.Debugf("%s: skipping rule %s because condition is not met\n", name, rule.Name) + continue + } + if slices.Contains(l.options.SkipRules, rule.Name) { + log.Debugf("%s: skipping rule %s because --skip-rule flag set\n", name, rule.Name) + continue + } + if slices.Contains(namesToPkg[name].NoLint, rule.Name) { + log.Debugf("%s: skipping rule %s because file contains #nolint:%s\n", name, rule.Name, rule.Name) + continue + } + if err := rule.LintFunc(namesToPkg[name].Config); err != nil { + if rule.Severity.Value <= minSeverity.Value { + msg := fmt.Sprintf("[%s]: %s (%s)", rule.Name, err.Error(), rule.Severity.Name) + failedRules = append(failedRules, EvalRuleError{Rule: rule, Error: fmt.Errorf("%s", msg)}) + } + } + } + if failedRules.WrapErrors() != nil { + results = append(results, EvalResult{File: name, Errors: failedRules}) + } + } + + return results, nil +} + +// Print prints lint results. +func (l *Linter) Print(ctx context.Context, result Result) { + log := clog.FromContext(ctx) + foundAny := false + for _, res := range result { + if res.Errors.WrapErrors() != nil { + foundAny = true + log.Errorf("Package: %s: %s", res.File, res.Errors.WrapErrors()) + } + } + if !foundAny { + log.Infof("No linting issues found!") + } +} + +// PrintRules prints the rules to stdout. +func (l *Linter) PrintRules(ctx context.Context) { + log := clog.FromContext(ctx) + log.Info("Available rules:") + for _, rule := range AllRules(l) { + log.Infof("* %s: %s\n", rule.Name, cases.Title(language.Und).String(rule.Description)) + } +} diff --git a/pkg/configlint/options.go b/pkg/configlint/options.go new file mode 100644 index 000000000..e653c828a --- /dev/null +++ b/pkg/configlint/options.go @@ -0,0 +1,23 @@ +package configlint + +// Options represents the options to configure the linter. +type Options struct { + // Path is the path to the file or directory to lint + Path string + + // SkipRules removes the given slice of rules from evaluation + SkipRules []string +} + +// Option represents a linter option. +type Option func(*Options) + +// WithPath sets the path to the file or directory to lint. +func WithPath(path string) Option { + return func(o *Options) { o.Path = path } +} + +// WithSkipRules sets the skip rules option. +func WithSkipRules(skipRules []string) Option { + return func(o *Options) { o.SkipRules = skipRules } +} diff --git a/pkg/configlint/repo.go b/pkg/configlint/repo.go new file mode 100644 index 000000000..2e753da1f --- /dev/null +++ b/pkg/configlint/repo.go @@ -0,0 +1,129 @@ +package configlint + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "chainguard.dev/melange/pkg/config" +) + +const yamlExtension = ".yaml" + +// Packages represents a Melange package configuration loaded from disk. +type Packages struct { + Config config.Configuration + Filename string + Dir string + NoLint []string + Hash string +} + +type configCheck struct { + Package struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + } `yaml:"package"` +} + +func (c configCheck) isMelangeConfig() bool { + if c.Package.Name == "" { + return false + } + if c.Package.Version == "" { + return false + } + return true +} + +// findNoLint reads the given file and returns any #nolint directives. +func findNoLint(filename string) ([]string, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + lines := strings.Split(string(b), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "#nolint:") { + return strings.Split(strings.TrimPrefix(line, "#nolint:"), ","), nil + } + } + return nil, nil +} + +// ReadAllPackagesFromRepo walks the provided directory and returns all Melange +// package configurations it finds keyed by package name. +func ReadAllPackagesFromRepo(ctx context.Context, dir string) (map[string]*Packages, error) { + p := make(map[string]*Packages) + + var fileList []string + err := filepath.WalkDir(dir, func(path string, fi os.DirEntry, _ error) error { + if fi == nil { + return fmt.Errorf("%s does not exist", dir) + } + if fi.IsDir() && path != dir { + return filepath.SkipDir + } + if filepath.Ext(path) == yamlExtension { + fileList = append(fileList, path) + } + return nil + }) + if err != nil { + return p, fmt.Errorf("failed walking files in cloned directory %s: %w", dir, err) + } + + sort.Strings(fileList) + + for _, fi := range fileList { + data, err := os.ReadFile(fi) + if err != nil { + return p, fmt.Errorf("failed to read file %s: %w", fi, err) + } + check := &configCheck{} + if err := yaml.Unmarshal(data, check); err != nil { + continue + } + if !check.isMelangeConfig() { + continue + } + + packageConfig, err := config.ParseConfiguration(ctx, fi) + if err != nil { + return p, fmt.Errorf("failed to read package config %s: %w", fi, err) + } + relativeFilename, err := filepath.Rel(dir, fi) + if err != nil { + return p, fmt.Errorf("failed to get relative path from dir %s and file %s package config %s: %w", dir, fi, packageConfig.Package.Name, err) + } + + nolint, err := findNoLint(fi) + if err != nil { + return p, fmt.Errorf("failed to read package config %s: %w", fi, err) + } + + name := packageConfig.Package.Name + fiBase := strings.TrimSuffix(filepath.Base(fi), filepath.Ext(fi)) + if name != fiBase { + return p, fmt.Errorf("package name does not match file name in '%s': '%s' != '%s'", fi, name, fiBase) + } + + if _, exists := p[name]; exists { + return p, fmt.Errorf("package config names must be unique. Found a package called '%s' in '%s' and '%s'", name, fi, p[name].Filename) + } + + p[name] = &Packages{ + Config: *packageConfig, + Filename: relativeFilename, + Dir: dir, + NoLint: nolint, + } + } + + return p, nil +} diff --git a/pkg/configlint/rules.go b/pkg/configlint/rules.go new file mode 100644 index 000000000..18d7b3080 --- /dev/null +++ b/pkg/configlint/rules.go @@ -0,0 +1,561 @@ +package configlint + +import ( + "fmt" + "net/url" + "os" + "regexp" + "strings" + + "chainguard.dev/melange/pkg/renovate" + "github.com/dprotaso/go-yit" + "github.com/github/go-spdx/v2/spdxexp" + "github.com/texttheater/golang-levenshtein/levenshtein" + "gopkg.in/yaml.v3" + + "golang.org/x/exp/slices" + + "chainguard.dev/melange/pkg/config" + "chainguard.dev/melange/pkg/versions" +) + +var ( + reValidSHA256 = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) + reValidSHA512 = regexp.MustCompile(`^[a-fA-F0-9]{128}$`) + reValidSHA1 = regexp.MustCompile(`^[a-fA-F0-9]{40}$`) + // Be stricter than Go to promote consistency and avoid homograph attacks + reValidHostname = regexp.MustCompile(`^[a-z0-9][a-z0-9\.\-]+\.[a-z]{2,6}$`) + + forbiddenRepositories = []string{ + "https://packages.wolfi.dev/os", + } + + forbiddenKeyrings = []string{ + "https://packages.wolfi.dev/os/wolfi-signing.rsa.pub", + } + + // Used for comparing hosts between configs + seenHosts = map[string]bool{} + // The minimum edit distance between two hostnames + minhostEditDistance = 2 + // Exceptions to the above rule + hostEditDistanceExceptions = map[string]string{ + "www.libssh.org": "www.libssh2.org", + } + + // Detect background processes (commands ending with '&' or '& sleep ...') or daemonized commands + reBackgroundProcess = regexp.MustCompile(`(?:^|[^&])&(?:\s*$|\s+sleep\b)`) // matches 'cmd &' or 'cmd & sleep' + reDaemonProcess = regexp.MustCompile(`.*(?:` + strings.Join(daemonFlags, "|") + `).*`) + // Detect output redirection in shell commands + reOutputRedirect = regexp.MustCompile(strings.Join(redirPatterns, "|")) +) + +var ( + daemonFlags = []string{ + `(?:^|\s)--daemon\b`, + `(?:^|\s)--daemonize\b`, + `(?:^|\s)--detach\b`, + `(?:^|\s)-daemon\b`, + } + + redirPatterns = []string{ + `>\s*\S+`, + `>>\s*\S+`, + `2>\s*\S+`, + `2>>\s*\S+`, + `&>\s*\S+`, + `&>>\s*\S+`, + `>\s*\S+.*2>&1`, + `2>&1.*>\s*\S+`, + `>\s*/dev/null`, + `2>\s*/dev/null`, + `&>\s*/dev/null`, + `\d+>&\d+`, + } +) + +const gitCheckout = "git-checkout" + +// AllRules is a list of all available rules to evaluate. +func AllRules(l *Linter) Rules { //nolint:gocyclo + return Rules{ + { + Name: "forbidden-repository-used", + Description: "do not specify a forbidden repository", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, repo := range config.Environment.Contents.BuildRepositories { + if slices.Contains(forbiddenRepositories, repo) { + return fmt.Errorf("forbidden repository %s is used", repo) + } + } + for _, repo := range config.Environment.Contents.RuntimeRepositories { + if slices.Contains(forbiddenRepositories, repo) { + return fmt.Errorf("forbidden repository %s is used", repo) + } + } + return nil + }, + }, + { + Name: "forbidden-keyring-used", + Description: "do not specify a forbidden keyring", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, keyring := range config.Environment.Contents.Keyring { + if slices.Contains(forbiddenKeyrings, keyring) { + return fmt.Errorf("forbidden keyring %s is used", keyring) + } + } + return nil + }, + }, + { + Name: "valid-copyright-header", + Description: "every package should have a valid copyright header", + Severity: SeverityInfo, + LintFunc: func(config config.Configuration) error { + if len(config.Package.Copyright) == 0 { + return fmt.Errorf("copyright header is missing") + } + for _, c := range config.Package.Copyright { + if c.License == "" { + return fmt.Errorf("license is missing") + } + } + return nil + }, + }, + { + Name: "contains-epoch", + Description: "every package should have an epoch", + Severity: SeverityError, + LintFunc: func(_ config.Configuration) error { + var node yaml.Node + fileInfo, err := os.Stat(l.options.Path) + if err != nil { + return err + } + + if fileInfo.IsDir() { + return nil + } + + yamlData, err := os.ReadFile(l.options.Path) + if err != nil { + return err + } + + err = yaml.Unmarshal(yamlData, &node) + if err != nil { + return err + } + + if node.Content == nil { + return fmt.Errorf("config %s has no yaml content", l.options.Path) + } + + pkg, err := renovate.NodeFromMapping(node.Content[0], "package") + if err != nil { + return err + } + + if pkg == nil { + return fmt.Errorf("config %s has no package content", l.options.Path) + } + + err = containsKey(pkg, "epoch") + if err != nil { + return fmt.Errorf("config %s has no package.epoch", l.options.Path) + } + + return nil + }, + }, + { + Name: "valid-pipeline-fetch-uri", + Description: "every fetch pipeline should have a valid uri", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, p := range config.Pipeline { + uri, err := extractURI(p) + if err != nil { + return err + } + if uri == "" { + continue + } + u, err := url.ParseRequestURI(uri) + if err != nil { + return fmt.Errorf("uri is invalid URL structure") + } + if !reValidHostname.MatchString(u.Host) { + return fmt.Errorf("uri hostname %q is invalid", u.Host) + } + } + return nil + }, + }, + { + Name: "uri-mimic", + Description: "every config should use a consistent hostname", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, p := range config.Pipeline { + uri := p.With["uri"] + if uri == "" { + continue + } + u, err := url.ParseRequestURI(uri) + if err != nil { + return nil + } + host := u.Host + if seenHosts[host] { + continue + } + for k := range seenHosts { + dist := levenshtein.DistanceForStrings([]rune(host), []rune(k), levenshtein.DefaultOptions) + if hostEditDistanceExceptions[host] == k || hostEditDistanceExceptions[k] == host { + continue + } + if dist <= minhostEditDistance { + return fmt.Errorf("%q too similar to %q", host, k) + } + + hostParts := strings.Split(host, ".") + kParts := strings.Split(k, ".") + if strings.Join(hostParts[:len(hostParts)-1], ".") == strings.Join(kParts[:len(kParts)-1], ".") { + return fmt.Errorf("%q shares components with %q", host, k) + } + } + seenHosts[host] = true + } + return nil + }, + }, + + { + Name: "valid-pipeline-fetch-digest", + Description: "every fetch pipeline should have a valid digest", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, p := range config.Pipeline { + if p.Uses == "fetch" { + hashGiven := false + if sha256, ok := p.With["expected-sha256"]; ok { + if !reValidSHA256.MatchString(sha256) { + return fmt.Errorf("expected-sha256 is not valid SHA256") + } + hashGiven = true + } + if sha512, ok := p.With["expected-sha512"]; ok { + if !reValidSHA512.MatchString(sha512) { + return fmt.Errorf("expected-sha512 is not valid SHA512") + } + hashGiven = true + } + if !hashGiven { + return fmt.Errorf("expected-sha256 or expected-sha512 is missing") + } + } + } + return nil + }, + }, + { + Name: "no-repeated-deps", + Description: "no repeated dependencies", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + seen := map[string]struct{}{} + for _, p := range config.Environment.Contents.Packages { + if _, ok := seen[p]; ok { + return fmt.Errorf("package %s is duplicated in environment", p) + } + seen[p] = struct{}{} + } + return nil + }, + }, + { + Name: "bad-template-var", + Description: "bad template variable", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + badTemplateVars := []string{ + "$pkgdir", + "$pkgver", + "$pkgname", + "$srcdir", + } + + hasBadVar := func(runs string) error { + for _, badVar := range badTemplateVars { + if strings.Contains(runs, badVar) { + return fmt.Errorf("package contains likely incorrect template var %s", badVar) + } + } + return nil + } + + for _, s := range config.Pipeline { + if err := hasBadVar(s.Runs); err != nil { + return err + } + } + + for _, subPkg := range config.Subpackages { + for _, subPipeline := range subPkg.Pipeline { + if err := hasBadVar(subPipeline.Runs); err != nil { + return err + } + } + } + return nil + }, + }, + { + Name: "bad-version", + Description: "version is malformed", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + version := config.Package.Version + if err := versions.ValidateWithoutEpoch(version); err != nil { + return fmt.Errorf("invalid version %s, could not parse", version) + } + return nil + }, + }, + { + Name: "valid-pipeline-git-checkout-commit", + Description: "every git-checkout pipeline should have a valid expected-commit", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, p := range config.Pipeline { + if p.Uses == gitCheckout { + if commit, ok := p.With["expected-commit"]; ok { + if !reValidSHA1.MatchString(commit) { + return fmt.Errorf("expected-commit is not valid SHA1") + } + } else { + return fmt.Errorf("expected-commit is missing") + } + } + } + return nil + }, + }, + { + Name: "valid-pipeline-git-checkout-tag", + Description: "every git-checkout pipeline should have a tag", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, p := range config.Pipeline { + if p.Uses == gitCheckout { + if _, ok := p.With["tag"]; !ok { + return fmt.Errorf("tag is missing") + } + } + } + return nil + }, + }, + { + Name: "check-when-version-changes", + Description: "check comments to make sure they are updated when version changes", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + re := regexp.MustCompile(`# CHECK-WHEN-VERSION-CHANGES: (.+)`) + checkString := func(s string) error { + match := re.FindStringSubmatch(s) + if len(match) == 0 { + return nil + } + for _, m := range match[1:] { + if m != config.Package.Version { + return fmt.Errorf("version in comment: %s does not match version in package: %s, check that it can be updated and update the comment", m, config.Package.Version) + } + } + return nil + } + for _, p := range config.Pipeline { + if err := checkString(p.Runs); err != nil { + return err + } + } + for _, subPkg := range config.Subpackages { + for _, subPipeline := range subPkg.Pipeline { + if err := checkString(subPipeline.Runs); err != nil { + return err + } + } + } + return nil + }, + }, + { + Name: "tagged-repository-in-environment-repos", + Description: "remove tagged repositories like @local from the repositories block", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, repo := range config.Environment.Contents.BuildRepositories { + if repo[0] == '@' { + return fmt.Errorf("repository %q is tagged", repo) + } + } + for _, repo := range config.Environment.Contents.RuntimeRepositories { + if repo[0] == '@' { + return fmt.Errorf("repository %q is tagged", repo) + } + } + return nil + }, + }, + { + Name: "git-checkout-must-use-github-updates", + Description: "when using git-checkout, must use github/git updates so we can get the expected-commit", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, p := range config.Pipeline { + if p.Uses == gitCheckout && strings.HasPrefix(p.With["repository"], "https://github.com/") { + if config.Update.Enabled && config.Update.GitHubMonitor == nil && config.Update.GitMonitor == nil { + return fmt.Errorf("configure update.github/update.git when using git-checkout") + } + } + } + return nil + }, + }, + { + Name: "valid-spdx-license", + Description: "every package should have a valid SPDX license", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + for _, c := range config.Package.Copyright { + switch c.License { + case "custom", "PROPRIETARY": + continue + } + if valid, _ := spdxexp.ValidateLicenses([]string{c.License}); !valid { + return fmt.Errorf("license %q is not valid SPDX license", c.License) + } + } + return nil + }, + }, + { + Name: "valid-package-or-subpackage-test", + Description: "every package should have a valid main or subpackage test", + Severity: SeverityInfo, + LintFunc: func(c config.Configuration) error { + if c.Test != nil && len(c.Test.Pipeline) > 0 { + return nil + } + for _, sp := range c.Subpackages { + if sp.Test != nil && len(sp.Test.Pipeline) > 0 { + return nil + } + } + return fmt.Errorf("no main package or subpackage test found") + }, + }, + { + Name: "update-disabled-reason", + Description: "packages with auto-update disabled should have a reason", + Severity: SeverityWarning, + LintFunc: func(c config.Configuration) error { + cfg := c.Update + if cfg.Enabled { + return nil + } + if !cfg.Enabled && cfg.ExcludeReason != "" { + return nil + } + return fmt.Errorf("auto-update is disabled but no reason is provided") + }, + }, + { + Name: "background-process-without-redirect", + Description: "test steps should redirect output when running background processes", + Severity: SeverityWarning, + LintFunc: func(c config.Configuration) error { + checkSteps := func(steps []config.Pipeline) error { + for _, s := range steps { + if s.Runs == "" { + continue + } + lines := strings.Split(s.Runs, "\n") + for i, line := range lines { + checkLine := line + if strings.Contains(line, "&") && i+1 < len(lines) { + checkLine += "\n" + lines[i+1] + } + needsRedirect := reBackgroundProcess.MatchString(checkLine) || reDaemonProcess.MatchString(line) + if needsRedirect && !reOutputRedirect.MatchString(line) { + return fmt.Errorf("background process missing output redirect: %s", strings.TrimSpace(line)) + } + } + } + return nil + } + if c.Test != nil { + if err := checkSteps(c.Test.Pipeline); err != nil { + return err + } + } + for _, sp := range c.Subpackages { + if sp.Test != nil { + if err := checkSteps(sp.Test.Pipeline); err != nil { + return err + } + } + } + return nil + }, + }, + { + Name: "valid-update-schedule", + Description: "update schedule config should contain a valid period", + Severity: SeverityError, + LintFunc: func(config config.Configuration) error { + if config.Update.Schedule == nil { + return nil + } + _, err := config.Update.Schedule.GetScheduleMessage() + return err + }, + }, + } +} + +func containsKey(parentNode *yaml.Node, key string) error { + it := yit.FromNode(parentNode). + ValuesForMap(yit.WithValue(key), yit.All) + + if _, ok := it(); ok { + return nil + } + + return fmt.Errorf("key '%s' not found in mapping", key) +} + +func extractURI(p config.Pipeline) (string, error) { + if p.Uses == "fetch" { + uri, ok := p.With["uri"] + if !ok { + return "", fmt.Errorf("uri is missing in fetch pipeline") + } + return uri, nil + } + + if p.Uses == "git-checkout" { + repo, ok := p.With["repository"] + if !ok { + return "", fmt.Errorf("repository is missing in git-checkout pipeline") + } + return repo, nil + } + + return "", nil +} diff --git a/pkg/configlint/types.go b/pkg/configlint/types.go new file mode 100644 index 000000000..e3544df62 --- /dev/null +++ b/pkg/configlint/types.go @@ -0,0 +1,78 @@ +package configlint + +import "errors" + +import "chainguard.dev/melange/pkg/config" + +// Function lints a single configuration. +type Function func(config.Configuration) error + +// ConditionFunc returns whether a rule should be executed. +type ConditionFunc func() bool + +// Severity represents a severity level. +type Severity struct { + Name string + Value int +} + +const ( + SeverityErrorLevel = iota + SeverityWarningLevel + SeverityInfoLevel +) + +var ( + SeverityError = Severity{"ERROR", SeverityErrorLevel} + SeverityWarning = Severity{"WARNING", SeverityWarningLevel} + SeverityInfo = Severity{"INFO", SeverityInfoLevel} +) + +// Rule represents a linter rule. +type Rule struct { + Name string + Description string + Severity Severity + LintFunc Function + ConditionFuncs []ConditionFunc +} + +// Rules is a list of Rule. +type Rules []Rule + +// EvalRuleError is an error during rule evaluation. +type EvalRuleError struct { + Rule Rule + Error error +} + +// EvalRuleErrors is a list of EvalRuleError. +type EvalRuleErrors []EvalRuleError + +// EvalResult is the result for a configuration file. +type EvalResult struct { + File string + Errors EvalRuleErrors +} + +// Result is a list of EvalResult. +type Result []EvalResult + +// HasErrors returns true if any EvalResult contains errors. +func (r Result) HasErrors() bool { + for _, res := range r { + if res.Errors.WrapErrors() != nil { + return true + } + } + return false +} + +// WrapErrors joins errors into one. +func (e EvalRuleErrors) WrapErrors() error { + errs := []error{} + for _, er := range e { + errs = append(errs, er.Error) + } + return errors.Join(errs...) +} diff --git a/pkg/versions/validate.go b/pkg/versions/validate.go new file mode 100644 index 000000000..db58f4aff --- /dev/null +++ b/pkg/versions/validate.go @@ -0,0 +1,39 @@ +package versions + +import ( + "errors" + "regexp" +) + +var ( + versionRegex = func() *regexp.Regexp { + re := regexp.MustCompile(`^([0-9]+)((\.[0-9]+)*)([a-z]?)((_alpha|_beta|_pre|_rc)([0-9]*))?((_cvs|_svn|_git|_hg|_p)([0-9]*))?((-r)([0-9]+))?$`) + re.Longest() + return re + }() + + versionWithEpochRegex = func() *regexp.Regexp { + re := regexp.MustCompile(`^([0-9]+)((\.[0-9]+)*)([a-z]?)((_alpha|_beta|_pre|_rc)([0-9]*))?((_cvs|_svn|_git|_hg|_p)([0-9]*))?((-r)([0-9]+))?(-r[0-9]+)$`) + re.Longest() + return re + }() +) + +var ( + ErrInvalidVersion = errors.New("not a valid Wolfi package version") + ErrInvalidFullVersion = errors.New("not a valid full Wolfi package version (with epoch)") +) + +func ValidateWithoutEpoch(v string) error { + if !versionRegex.MatchString(v) { + return ErrInvalidVersion + } + return nil +} + +func ValidateWithEpoch(v string) error { + if !versionWithEpochRegex.MatchString(v) { + return ErrInvalidFullVersion + } + return nil +}