blob: aaf80143f783c619acf5b8a21b6ba1b254585c0e [file] [log] [blame]
// Copyright 2019 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package pbutil
import (
"crypto/sha256"
"fmt"
"regexp"
"sort"
"strings"
"time"
structpb "github.com/golang/protobuf/ptypes/struct"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/common/errors"
pb "go.chromium.org/luci/resultdb/proto/v1"
)
const MaxSizeInvocationProperties = 16 * 1024 // 16 KB
const MaxSizeTestMetadataProperties = 4 * 1024 // 4 KB
// MaxSizeTestResultProperties is the maximum size of the test result
// properties.
//
// CAVEAT: before increasing the size limit, verify if it will break downstream
// services. Notably the test verdict exports. BigQuery has a 10 MB AppendRows
// request size limit[1]. Each verdict can have a maximum of 100 test results[2]
// as of 2024-04-11.
//
// [1]: https://cloud.google.com/bigquery/quotas#write-api-limits
// [2]: https://chromium.googlesource.com/infra/luci/luci-go/+/e83766e441050596aaaa22dbdaad4228bacf0929/resultdb/internal/testvariants/query.go#49
const MaxSizeTestResultProperties = 8 * 1024 // 8 KB
const MaxInstructionsSize = 1024 * 1024 // 1 MB
const MaxInstructionSize = 10 * 1024 // 10 KB
const MaxDependencyBuildIDSize = 100
const MaxDependencyStepNameSize = 1024
const MaxDependencyStepTagKeySize = 256
const MaxDependencyStepTagValSize = 1024
var requestIDRe = regexp.MustCompile(`^[[:ascii:]]{0,36}$`)
// Allow hostnames permitted by
// https://www.rfc-editor.org/rfc/rfc1123#page-13. (Note that
// the 255 character limit must be seperately applied.)
var hostnameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]+(\.[a-z0-9-]+)*$`)
// The maximum hostname permitted by
// https://www.rfc-editor.org/rfc/rfc1123#page-13.
const hostnameMaxLength = 255
var sha1Regex = regexp.MustCompile(`^[a-f0-9]{40}$`)
func regexpf(patternFormat string, subpatterns ...any) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(patternFormat, subpatterns...))
}
func doesNotMatch(r *regexp.Regexp) error {
return errors.Reason("does not match %s", r).Err()
}
func unspecified() error {
return errors.Reason("unspecified").Err()
}
func validateWithRe(re *regexp.Regexp, value string) error {
if value == "" {
return unspecified()
}
if !re.MatchString(value) {
return doesNotMatch(re)
}
return nil
}
// MustTimestampProto converts a time.Time to a *timestamppb.Timestamp and panics
// on failure.
func MustTimestampProto(t time.Time) *timestamppb.Timestamp {
ts := timestamppb.New(t)
if err := ts.CheckValid(); err != nil {
panic(err)
}
return ts
}
// MustTimestamp converts a *timestamppb.Timestamp to a time.Time and panics
// on failure.
func MustTimestamp(ts *timestamppb.Timestamp) time.Time {
if err := ts.CheckValid(); err != nil {
panic(err)
}
t := ts.AsTime()
return t
}
// ValidateRequestID returns a non-nil error if requestID is invalid.
// Returns nil if requestID is empty.
func ValidateRequestID(requestID string) error {
if !requestIDRe.MatchString(requestID) {
return doesNotMatch(requestIDRe)
}
return nil
}
// ValidateBatchRequestCount validates the number of requests in a batch
// request.
func ValidateBatchRequestCount(count int) error {
const limit = 500
if count > limit {
return errors.Reason("the number of requests in the batch exceeds %d", limit).Err()
}
return nil
}
// ValidateEnum returns a non-nil error if the value is not among valid values.
func ValidateEnum(value int32, validValues map[int32]string) error {
if _, ok := validValues[value]; !ok {
return errors.Reason("invalid value %d", value).Err()
}
return nil
}
// MustDuration converts a *durationpb.Duration to a time.Duration and panics
// on failure.
func MustDuration(du *durationpb.Duration) time.Duration {
if err := du.CheckValid(); err != nil {
panic(err)
}
d := du.AsDuration()
return d
}
// MustMarshal marshals a protobuf message and panics on failure.
func MustMarshal(m protoreflect.ProtoMessage) []byte {
msg, err := proto.Marshal(m)
if err != nil {
panic(err)
}
return msg
}
// validateProperties returns a non-nil error if properties is invalid.
func validateProperties(properties *structpb.Struct, maxSize int) error {
if proto.Size(properties) > maxSize {
return errors.Reason("exceeds the maximum size of %d bytes", maxSize).Err()
}
return nil
}
// ValidateInvocationProperties returns a non-nil error if properties is invalid.
func ValidateInvocationProperties(properties *structpb.Struct) error {
return validateProperties(properties, MaxSizeInvocationProperties)
}
// ValidateTestResultProperties returns a non-nil error if properties is invalid.
func ValidateTestResultProperties(properties *structpb.Struct) error {
return validateProperties(properties, MaxSizeTestResultProperties)
}
// ValidateTestMetadataProperties returns a non-nil error if properties is invalid.
func ValidateTestMetadataProperties(properties *structpb.Struct) error {
return validateProperties(properties, MaxSizeTestMetadataProperties)
}
// ValidateGitilesCommit validates a gitiles commit.
func ValidateGitilesCommit(commit *pb.GitilesCommit) error {
switch {
case commit == nil:
return errors.Reason("unspecified").Err()
case commit.Host == "":
return errors.Reason("host: unspecified").Err()
case len(commit.Host) > 255:
return errors.Reason("host: exceeds 255 characters").Err()
case !hostnameRE.MatchString(commit.Host):
return errors.Reason("host: does not match %q", hostnameRE).Err()
case commit.Project == "":
return errors.Reason("project: unspecified").Err()
case len(commit.Project) > hostnameMaxLength:
return errors.Reason("project: exceeds %v characters", hostnameMaxLength).Err()
case commit.Ref == "":
return errors.Reason("ref: unspecified").Err()
// The 255 character ref limit is arbitrary and not based on a known
// restriction in Git. It exists simply because there should be a limit
// to protect downstream clients.
case len(commit.Ref) > 255:
return errors.Reason("ref: exceeds 255 characters").Err()
case !strings.HasPrefix(commit.Ref, "refs/"):
return errors.Reason("ref: does not match refs/.*").Err()
case commit.CommitHash == "":
return errors.Reason("commit_hash: unspecified").Err()
case !sha1Regex.MatchString(commit.CommitHash):
return errors.Reason("commit_hash: does not match %q", sha1Regex).Err()
case commit.Position == 0:
return errors.Reason("position: unspecified").Err()
case commit.Position < 0:
return errors.Reason("position: cannot be negative").Err()
}
return nil
}
// ValidateGerritChange validates a gerrit change.
func ValidateGerritChange(change *pb.GerritChange) error {
switch {
case change == nil:
return errors.Reason("unspecified").Err()
case change.Host == "":
return errors.Reason("host: unspecified").Err()
case len(change.Host) > hostnameMaxLength:
return errors.Reason("host: exceeds %v characters", hostnameMaxLength).Err()
case !hostnameRE.MatchString(change.Host):
return errors.Reason("host: does not match %q", hostnameRE).Err()
case change.Project == "":
return errors.Reason("project: unspecified").Err()
// The 255 character project limit is arbitrary and not based on a known
// restriction in Gerrit. It exists simply because there should be a limit
// to protect downstream clients.
case len(change.Project) > 255:
return errors.Reason("project: exceeds 255 characters").Err()
case change.Change == 0:
return errors.Reason("change: unspecified").Err()
case change.Change < 0:
return errors.Reason("change: cannot be negative").Err()
case change.Patchset == 0:
return errors.Reason("patchset: unspecified").Err()
case change.Patchset < 0:
return errors.Reason("patchset: cannot be negative").Err()
default:
return nil
}
}
func ValidateTestInstruction(instruction *pb.Instruction) error {
// We allows invocation with no test instructions.
if instruction == nil {
return nil
}
if err := ValidateInstruction(instruction); err != nil {
return errors.Annotate(err, "test instruction").Err()
}
return nil
}
func ValidateStepInstructions(instructions *pb.Instructions) error {
// We allows invocation with no step instructions.
if instructions == nil {
return nil
}
if proto.Size(instructions) > MaxInstructionsSize {
return errors.Reason("step instructions: bigger than %d bytes", MaxInstructionsSize).Err()
}
idMap := map[string]int{}
for i, instruction := range instructions.Instructions {
// Make sure that all instructions have id, and id are unique.
if instruction.Id == "" {
return errors.Reason("step instructions: unspecified id").Err()
}
if index, ok := idMap[instruction.Id]; ok {
return errors.Reason("step instructions: ID %q is re-used at index %d and %d", instruction.Id, index, i).Err()
}
idMap[instruction.Id] = i
if err := ValidateInstruction(instruction); err != nil {
return errors.Annotate(err, "step instructions").Err()
}
}
return nil
}
func ValidateInstruction(instruction *pb.Instruction) error {
targetMap := map[pb.InstructionTarget]bool{}
for _, targetedInstruction := range instruction.TargetedInstructions {
// Check that targets are not empty.
if len(targetedInstruction.Targets) == 0 {
return errors.Reason("target: empty").Err()
}
// Check that targets are valid.
for _, target := range targetedInstruction.Targets {
if target == pb.InstructionTarget_INSTRUCTION_TARGET_UNSPECIFIED {
return errors.Reason("target: unspecified").Err()
}
if _, ok := targetMap[target]; ok {
return errors.Reason("target: duplicated %q", target).Err()
}
targetMap[target] = true
}
// Make sure content size <= 10KB.
// TODO (nqmtuan): Validate this is a valid mustache template.
if len(targetedInstruction.Content) > MaxInstructionSize {
return errors.Reason("content: longer than %v bytes", MaxInstructionSize).Err()
}
// Check dependency.
if err := ValidateDependencies(targetedInstruction.Dependency); err != nil {
return err
}
}
return nil
}
func ValidateDependencies(dependencies []*pb.InstructionDependency) error {
if len(dependencies) == 0 {
return nil
}
if len(dependencies) > 1 {
return errors.Reason("dependency: more than 1").Err()
}
for i, dep := range dependencies {
if err := ValidateDependency(dep); err != nil {
return errors.Annotate(err, "dependencies[%v]", i).Err()
}
}
return nil
}
func ValidateDependency(dependency *pb.InstructionDependency) error {
if len(dependency.BuildId) > MaxDependencyBuildIDSize {
return errors.Reason("build_id: longer than %v bytes", MaxDependencyBuildIDSize).Err()
}
if dependency.StepName == "" {
return errors.Reason("step_name: empty").Err()
}
if len(dependency.StepName) > MaxDependencyStepNameSize {
return errors.Reason("step_name: longer than %v bytes", MaxDependencyStepNameSize).Err()
}
if len(dependency.GetStepTag().GetKey()) > MaxDependencyStepTagKeySize {
return errors.Reason("step_tag_key: longer than %v bytes", MaxDependencyStepTagKeySize).Err()
}
if len(dependency.GetStepTag().GetValue()) > MaxDependencyStepTagValSize {
return errors.Reason("step_tag_val: longer than %v bytes", MaxDependencyStepTagValSize).Err()
}
// TODO (nqmtuan): Validate dependency.build_id is either integer or mustache format.
return nil
}
// SortGerritChanges sorts in-place the gerrit changes lexicographically.
func SortGerritChanges(changes []*pb.GerritChange) {
sort.Slice(changes, func(i, j int) bool {
if changes[i].Host != changes[j].Host {
return changes[i].Host < changes[j].Host
}
if changes[i].Project != changes[j].Project {
return changes[i].Project < changes[j].Project
}
if changes[i].Change != changes[j].Change {
return changes[i].Change < changes[j].Change
}
return changes[i].Patchset < changes[j].Patchset
})
}
// SourceRefFromSources extracts a SourceRef from given sources.
func SourceRefFromSources(srcs *pb.Sources) *pb.SourceRef {
return &pb.SourceRef{
System: &pb.SourceRef_Gitiles{
Gitiles: &pb.GitilesRef{
Host: srcs.GitilesCommit.Host,
Project: srcs.GitilesCommit.Project,
Ref: srcs.GitilesCommit.Ref,
},
}}
}
// SourceRefHash returns a short hash of the sourceRef.
func SourceRefHash(sr *pb.SourceRef) []byte {
var result [32]byte
switch sr.System.(type) {
case *pb.SourceRef_Gitiles:
gitiles := sr.GetGitiles()
result = sha256.Sum256([]byte("gitiles" + "\n" + gitiles.Host + "\n" + gitiles.Project + "\n" + gitiles.Ref))
default:
panic("invalid source ref")
}
return result[:8]
}