code/go/internal/validator/folder_spec.go (198 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.
package validator
import (
"errors"
"fmt"
"io/fs"
"log"
"path"
"regexp"
"strings"
"github.com/elastic/package-spec/v3/code/go/internal/packages"
"github.com/elastic/package-spec/v3/code/go/internal/spectypes"
"github.com/elastic/package-spec/v3/code/go/internal/validator/common"
"github.com/elastic/package-spec/v3/code/go/pkg/specerrors"
)
type validator struct {
spec spectypes.ItemSpec
pkg *packages.Package
folderPath string
totalSize spectypes.FileSize
totalContents int
}
func newValidator(spec spectypes.ItemSpec, pkg *packages.Package) *validator {
return newValidatorForPath(spec, pkg, ".")
}
func newValidatorForPath(spec spectypes.ItemSpec, pkg *packages.Package, folderPath string) *validator {
return &validator{
spec: spec,
pkg: pkg,
folderPath: folderPath,
}
}
func (v *validator) Validate() specerrors.ValidationErrors {
var errs specerrors.ValidationErrors
files, err := fs.ReadDir(v.pkg, v.folderPath)
if err != nil {
errs = append(errs,
specerrors.NewStructuredErrorf("could not read folder [%s]: %w", v.pkg.Path(v.folderPath), err),
)
return errs
}
// This is not taking into account if the folder is for development. Enforce
// this limit in all cases to avoid having to read too many files.
if contentsLimit := v.spec.MaxTotalContents(); contentsLimit > 0 && len(files) > contentsLimit {
errs = append(errs,
specerrors.NewStructuredErrorf("folder [%s] exceeds the limit of %d files", v.pkg.Path(v.folderPath), contentsLimit),
)
return errs
}
// Don't enable beta features for packages marked as GA.
switch v.spec.Release() {
case "", "ga": // do nothing
case "beta":
if v.pkg.IsGA() {
errs = append(errs,
specerrors.NewStructuredErrorf("spec for [%s] defines beta features which can't be enabled for packages with a stable semantic version", v.pkg.Path(v.folderPath)),
)
} else {
message := fmt.Sprintf("package with non-stable semantic version and active beta features (enabled in [%s]) can't be released as stable version.", v.pkg.Path(v.folderPath))
if common.IsDefinedWarningsAsErrors() || v.pkg.SpecVersion.Major() >= 3 {
err = errors.New(message)
errs = append(errs, specerrors.NewStructuredError(err, specerrors.CodePrereleaseFeatureOnGAPackage))
} else {
log.Print("Warning: ", message)
}
}
default:
errs = append(errs, specerrors.NewStructuredErrorf("unsupport release level, supported values: beta, ga"))
}
for _, file := range files {
fileName := file.Name()
itemSpec, err := v.findItemSpec(fileName)
if err != nil {
errs = append(errs, specerrors.NewStructuredError(err, specerrors.UnassignedCode))
continue
}
if itemSpec == nil && v.spec.AdditionalContents() {
// No spec found for current folder item, but we do allow additional contents in folder.
if file.IsDir() {
if !v.spec.DevelopmentFolder() && strings.Contains(fileName, "-") {
errs = append(errs,
specerrors.NewStructuredErrorf(
`file "%s" is invalid: directory name inside package %s contains -: %s`,
v.pkg.Path(v.folderPath, fileName), v.pkg.Name, fileName),
)
}
}
continue
}
if itemSpec == nil && !v.spec.AdditionalContents() {
// No spec found for current folder item and we do not allow additional contents in folder.
errs = append(errs,
specerrors.NewStructuredErrorf("item [%s] is not allowed in folder [%s]", fileName, v.pkg.Path(v.folderPath)),
)
continue
}
if file.IsDir() {
if !itemSpec.IsDir() {
errs = append(errs,
specerrors.NewStructuredErrorf("[%s] is a folder but is expected to be a file", fileName),
)
continue
}
subFolderPath := path.Join(v.folderPath, fileName)
itemValidator := newValidatorForPath(itemSpec, v.pkg, subFolderPath)
subErrs := itemValidator.Validate()
if len(subErrs) > 0 {
errs = append(errs, subErrs...)
}
// Don't count files in development folders.
if !itemSpec.DevelopmentFolder() {
v.totalContents += itemValidator.totalContents
v.totalSize += itemValidator.totalSize
}
} else {
if itemSpec.IsDir() {
errs = append(errs,
specerrors.NewStructuredErrorf("[%s] is a file but is expected to be a folder", v.pkg.Path(fileName)),
)
continue
}
itemPath := path.Join(v.folderPath, file.Name())
itemValidationErrs := validateFile(itemSpec, v.pkg, itemPath)
for _, ive := range itemValidationErrs {
errs = append(errs,
specerrors.NewStructuredErrorf("file \"%s\" is invalid: %w", v.pkg.Path(itemPath), ive),
)
}
info, err := fs.Stat(v.pkg, itemPath)
if err != nil {
errs = append(errs,
specerrors.NewStructuredErrorf("failed to obtain file size for \"%s\": %w", v.pkg.Path(itemPath), err),
)
} else {
v.totalContents++
v.totalSize += spectypes.FileSize(info.Size())
}
}
}
if sizeLimit := v.spec.MaxTotalSize(); sizeLimit > 0 && v.totalSize > sizeLimit {
errs = append(errs,
specerrors.NewStructuredErrorf("folder [%s] exceeds the total size limit of %s", v.pkg.Path(v.folderPath), sizeLimit),
)
}
// validate that required items in spec are all accounted for
for _, itemSpec := range v.spec.Contents() {
if !itemSpec.Required() {
continue
}
fileFound, err := matchingFileExists(itemSpec, files)
if err != nil {
errs = append(errs, specerrors.NewStructuredError(err, specerrors.UnassignedCode))
continue
}
if !fileFound {
var err error
if itemSpec.Name() != "" {
err = fmt.Errorf("expecting to find [%s] %s in folder [%s]", itemSpec.Name(), itemSpec.Type(), v.pkg.Path(v.folderPath))
} else if itemSpec.Pattern() != "" {
err = fmt.Errorf("expecting to find %s matching pattern [%s] in folder [%s]", itemSpec.Type(), itemSpec.Pattern(), v.pkg.Path(v.folderPath))
}
errs = append(errs, specerrors.NewStructuredError(err, specerrors.UnassignedCode))
}
}
return errs
}
func (v *validator) findItemSpec(folderItemName string) (spectypes.ItemSpec, error) {
isLink, folderItemName := checkLink(folderItemName)
for _, itemSpec := range v.spec.Contents() {
if itemSpec.Name() != "" && itemSpec.Name() == folderItemName {
return itemSpec, nil
}
if itemSpec.Pattern() != "" {
isMatch, err := regexp.MatchString(strings.ReplaceAll(itemSpec.Pattern(), "{PACKAGE_NAME}", v.pkg.Name), folderItemName)
if err != nil {
return nil, fmt.Errorf("invalid folder item spec pattern: %w", err)
}
if isMatch {
var isForbidden bool
for _, forbidden := range itemSpec.ForbiddenPatterns() {
isForbidden, err = regexp.MatchString(forbidden, folderItemName)
if err != nil {
return nil, fmt.Errorf("invalid forbidden pattern for folder item: %w", err)
}
if isForbidden {
break
}
}
if !isForbidden {
if isLink && !itemSpec.AllowLink() {
return nil, fmt.Errorf("item [%s] is a link but is not allowed", folderItemName)
}
return itemSpec, nil
}
}
}
}
// No item spec found
return nil, nil
}
// checkLink checks if an item is a link and returns the item name without the
// ".link" suffix if it is a link.
func checkLink(itemName string) (bool, string) {
const linkExtension = ".link"
if strings.HasSuffix(itemName, linkExtension) {
return true, strings.TrimSuffix(itemName, linkExtension)
}
return false, itemName
}