blob: de76ed9e65ac8af1ea220dbc294031cd1c855d5b [file]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package metadata provides functions to lint Chrome OS integration test
// metadata.
package metadata
import (
"fmt"
"net/url"
"strconv"
"strings"
"unicode"
"infra/cros/cmd/tclint/internal/diagnostics"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
"go.chromium.org/chromiumos/config/go/api/test/metadata/v1"
"go.chromium.org/luci/common/data/stringset"
exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)
// Lint checks a given metadata specification for violations of requirements
// stated in the API definition.
func Lint(spec *metadata.Specification) diagnostics.Result {
l := linter{}
return l.Lint(spec)
}
type linter struct {
result diagnostics.Result
}
func (l *linter) Lint(spec *metadata.Specification) diagnostics.Result {
// (Re)start with an empty result.
l.result = diagnostics.Result{}
if len(spec.RemoteTestDrivers) == 0 {
l.result.AppendError("Specification must contain at least one RemoteTestDriver")
return l.result
}
for _, rtd := range spec.RemoteTestDrivers {
l.lintRTD(rtd)
}
l.verifyUniqueRemoteTestDriverNames(spec.RemoteTestDrivers)
return l.result
}
func (l *linter) verifyUniqueRemoteTestDriverNames(rtds []*metadata.RemoteTestDriver) {
ns := make([]string, len(rtds))
for i, rtd := range rtds {
ns[i] = rtd.GetName()
}
if repeated := formatRepeated(ns); repeated != "" {
l.result.AppendError("RemoteTestDriver names must be unique, found repeated name(s): %s", repeated)
}
}
func formatRepeated(ss []string) string {
seen := stringset.New(len(ss))
repeated := stringset.New(len(ss))
for _, s := range ss {
if seen.Has(s) {
repeated.Add(fmt.Sprintf("'%s'", s))
}
seen.Add(s)
}
return strings.Join(repeated.ToSortedSlice(), ",")
}
func (l *linter) lintRTD(rtd *metadata.RemoteTestDriver) {
l.lintRTDName(rtd.GetName())
for _, t := range rtd.Tests {
l.lintTest(t, rtd.GetName())
}
drop := l.result.PushContext(fmt.Sprintf("RemoteTestDriver '%s'", rtd.GetName()))
defer drop()
l.verifyUniqueTestNames(rtd.Tests)
}
func (l *linter) verifyUniqueTestNames(tests []*metadata.Test) {
ns := make([]string, len(tests))
for i, test := range tests {
ns[i] = test.GetName()
}
if repeated := formatRepeated(ns); repeated != "" {
l.result.AppendError("Test names must be unique, found repeated name(s): %s", repeated)
}
}
const (
rtdCollection = "remoteTestDrivers"
testCollection = "tests"
)
func (l *linter) lintRTDName(name string) {
drop := l.result.PushContext(fmt.Sprintf("RemoteTestDriver '%s'", name))
defer drop()
if !l.lintResourceName(name) {
return
}
parts := strings.SplitN(name, "/", 3)
switch len(parts) {
case 0:
l.result.AppendError("name must be of the form '%s/{remoteTestDriver}', found empty string", rtdCollection)
case 1:
if parts[0] == rtdCollection {
l.result.AppendError("name must be of the form '%s/{remoteTestDriver}', missing name after '%s'", rtdCollection, rtdCollection)
} else {
l.result.AppendError("name must be of the form '%s/{remoteTestDriver}', missing prefix '%s'", rtdCollection, rtdCollection)
}
case 2:
if parts[0] != rtdCollection {
l.result.AppendError("name must be of the form '%s/{remoteTestDriver}', missing prefix '%s'", rtdCollection, rtdCollection)
}
default:
l.result.AppendError("name must be of the form '%s/{remoteTestDriver}', found trailing suffix '%s'", rtdCollection, parts[2])
}
}
func (l *linter) lintTest(test *metadata.Test, rtdName string) {
drop := l.result.PushContext(fmt.Sprintf("Test '%s'", test.GetName()))
defer drop()
l.lintTestName(test.GetName(), rtdName)
if test.DutConstraint != nil {
l.lintDutConstraint(test.DutConstraint)
}
}
func (l *linter) lintTestName(name string, rtdName string) {
if !l.lintResourceName(name) {
return
}
prefix := fmt.Sprintf("%s/", rtdName)
if !strings.HasPrefix(name, prefix) {
l.result.AppendError("name must be prefixed with RemoteTestDriver name '%s'", rtdName)
return
}
name = strings.TrimPrefix(name, prefix)
parts := strings.SplitN(name, "/", 3)
switch len(parts) {
case 0:
l.result.AppendError("name must be of the form '%s/{test}', found empty string", testCollection)
case 1:
if parts[0] == testCollection {
l.result.AppendError("name must be of the form '%s/{test}', missing name after '%s'", testCollection, testCollection)
} else {
l.result.AppendError("name must be of the form '%s/{test}', missing prefix '%s'", testCollection, testCollection)
}
case 2:
if parts[0] != testCollection {
l.result.AppendError("name must be of the form '%s/{test}', missing prefix '%s'", testCollection, testCollection)
}
default:
l.result.AppendError("name must be of the form '%s/{test}', found trailing suffix '%s'", testCollection, parts[2])
}
}
// lintResourceName lints resource names.
//
// This lint enforces some rules in addition to the recommendations in
// https://aip.dev/122.
//
// Returns true if argument was a valid resource name, false otherwise.
// Callers should not further lint an invalid resource name because it usually
// leads to spurious diagnostic messages.
//
// The argument _is not_ added as a context in diagnostic messages, because the
// caller can provide better context about the object being named (e.g.
// "RemoteTestDriver <name>" instead of "<name>").
func (l *linter) lintResourceName(name string) bool {
result := diagnostics.Result{}
defer func() {
l.result.Merge(result)
}()
if name == "" {
result.AppendError("name must be non-empty (https://aip.dev/122)")
return result.IsValid()
}
u, err := url.Parse(name)
if err != nil {
drop := l.result.PushContext("parse name")
defer drop()
result.AppendError(err.Error())
return result.IsValid()
}
if u.Scheme != "" {
result.AppendError("name must be a URL path component (https://aip.dev/122), found non-empty scheme '%s'", u.Scheme)
}
if u.Opaque != "" {
result.AppendError("name must be a URL path component (https://aip.dev/122), found non-empty opaque data '%s'", u.Opaque)
}
if u.User != nil {
result.AppendError("name must be a URL path component (https://aip.dev/122), found non-empty user information '%s'", u.User.String())
}
if u.Host != "" {
result.AppendError("name must be a URL path component (https://aip.dev/122), found non-empty host '%s'", u.Host)
}
if u.Fragment != "" {
result.AppendError("resource versions are not yet supported, found version '%s'", u.Fragment)
}
if u.Path == "" {
result.AppendError("name must be a non-empty URL path component (https://aip.dev/122), found empty path")
return result.IsValid()
}
if strings.HasPrefix(u.Path, "/") {
result.AppendError("name must be a URL relative path component (https://aip.dev/122), found absolute path '%s'", u.Path)
}
if strings.HasSuffix(u.Path, "/") {
result.AppendError("name must not contain a trailing '/' (https://aip.dev/122), found trailing '/' in '%s'", u.Path)
}
if !isASCII(u.Path) {
result.AppendError("name must only use ASCII characters, found non-ASCII chracters in '%s'", strconv.QuoteToASCII(u.Path))
}
return result.IsValid()
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > unicode.MaxASCII {
return false
}
}
return true
}
func (l *linter) lintDutConstraint(cond *metadata.DUTConstraint) {
drop := l.result.PushContext("dut_constraint")
defer drop()
if cond.Setup == nil && cond.Config == nil {
l.result.AppendError("some constraint must be set")
return
}
if cond.Setup != nil {
l.lintDUTSetupConstraint(cond.Setup)
}
if cond.Config != nil {
l.lintDUTConfigConstraint(cond.Config)
}
}
func (l *linter) lintDUTSetupConstraint(cond *metadata.DUTSetupConstraint) {
dutType := decls.NewObjectType("chromiumos.config.api.test.metadata.v1.DUTSetupConstraint.DUT")
dutValue := &metadata.DUTSetupConstraint_DUT{}
env, err := cel.NewEnv(
cel.Container("chromiumos.config.api.test.dut.v1"),
// Adding the type to environment allows the expression to use fully
// qualified type names like `chromiumos.config.api.test.dut.v1.Wifi`.
cel.Types(dutValue),
cel.Declarations(
decls.NewIdent("dut", dutType, nil),
),
)
if err != nil {
// Failure to setup the environment is a programming error that we never
// expect to recover from.
panic(err)
}
drop := l.result.PushContext("setup")
defer drop()
l.parseAndCheckExpression(env, cond.Expression)
}
func (l *linter) parseAndCheckExpression(env *cel.Env, expr string) {
if expr == "" {
l.result.AppendError("expression must be non-empty")
return
}
ast, iss := env.Compile(expr)
if iss.Err() != nil {
// Reported issues are frequently displayed on multiple lines.
// Adding a leading newline makes the multi-line display easier to read.
l.result.AppendError("compile expression: \n%s", iss.String())
return
}
if ast.ResultType().GetPrimitive() != exprpb.Type_BOOL {
l.result.AppendError("expression must evaluate to a boolean, found %s", ast.ResultType().String())
}
}
func (l *linter) lintDUTConfigConstraint(cond *metadata.DUTConfigConstraint) {
dutType := decls.NewObjectType("chromiumos.config.api.test.metadata.v1.DUTConfigConstraint.DUT")
dutValue := &metadata.DUTConfigConstraint_DUT{}
env, err := cel.NewEnv(
cel.Container("chromiumos.config.api"),
// Adding the type to environment allows the expression to use fully
// qualified type names like `chromiumos.config.api.HardwareFeatures`.
cel.Types(dutValue),
cel.Declarations(
decls.NewIdent("dut", dutType, nil),
),
)
if err != nil {
// Failure to setup the environment is a programming error that we never
// expect to recover from.
panic(err)
}
drop := l.result.PushContext("config")
defer drop()
l.parseAndCheckExpression(env, cond.Expression)
}