blob: c37054e2c19389270bdd3c498743a6b1a8b1ca9b [file] [log] [blame]
// Copyright 2021 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 model
import (
"fmt"
"strings"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/known/fieldmaskpb"
pb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/proto/mask"
"go.chromium.org/luci/common/proto/structmask"
)
// The default field mask to use for read requests.
var defaultFieldMask = fieldmaskpb.FieldMask{
Paths: []string{
"builder",
"canary",
"create_time",
"created_by",
"critical",
"end_time",
"id",
"input.experimental",
"input.gerrit_changes",
"input.gitiles_commit",
"number",
"start_time",
"status",
"status_details",
"update_time",
},
}
// Used just for their type information.
var (
buildPrototype = pb.Build{}
searchBuildPrototype = pb.SearchBuildsResponse{}
)
// NoopBuildMask selects all fields.
var NoopBuildMask = &BuildMask{m: mask.All(&buildPrototype)}
// DefaultBuildMask is the default mask to use for read requests.
var DefaultBuildMask = HardcodedBuildMask(defaultFieldMask.Paths...)
// ListOnlyBuildMask is an extra mask to hide fields from callers who have the BuildsList
// permission but not BuildsGet or BuildsGetLimited.
// These callers should only be able to see fields specified in this mask.
var ListOnlyBuildMask = HardcodedBuildMask(BuildFieldsWithVisibility(pb.BuildFieldVisibility_BUILDS_LIST_PERMISSION)...)
// GetLimitedBuildMask is an extra mask to hide fields from callers who have the BuildsGetLimited
// permission but not BuildsGet.
// These callers should only be able to see fields specified in this mask.
var GetLimitedBuildMask = HardcodedBuildMask(BuildFieldsWithVisibility(pb.BuildFieldVisibility_BUILDS_GET_LIMITED_PERMISSION)...)
// BuildMask knows how to filter pb.Build proto messages.
type BuildMask struct {
m *mask.Mask // the overall field mask
in *structmask.Filter // "input.properties" filter
out *structmask.Filter // "output.properties" filter
req *structmask.Filter // "infra.buildbucket.requested_properties" filter
stepStatuses map[pb.Status]struct{} // "steps.status" filter
allFields bool // Flag for including all fields.
}
// NewBuildMask constructs a build mask either using a legacy `fields` FieldMask
// or new `mask` BuildMask (but not both at the same time, pick one).
//
// legacyPrefix is usually "", but can be "builds" to trim "builds." from
// the legacy field mask (used by SearchBuilds API).
//
// If the mask is empty, returns DefaultBuildMask.
func NewBuildMask(legacyPrefix string, legacy *fieldmaskpb.FieldMask, bm *pb.BuildMask) (*BuildMask, error) {
switch {
case legacy == nil && bm == nil:
return DefaultBuildMask, nil
case legacy != nil && bm != nil:
return nil, errors.Reason("`mask` and `fields` can't be used together, prefer `mask` since `fields` is deprecated").Err()
case legacy != nil:
return newLegacyBuildMask(legacyPrefix, legacy)
}
// Filter unique statuses.
stepStatuses := make(map[pb.Status]struct{}, len(pb.Status_name))
for _, st := range bm.StepStatus {
stepStatuses[st] = struct{}{}
}
if bm.GetAllFields() {
// All fields should be included.
if len(bm.GetFields().GetPaths()) > 0 || len(bm.GetInputProperties()) > 0 || len(bm.GetOutputProperties()) > 0 || len(bm.GetRequestedProperties()) > 0 {
return nil, errors.New("mask.AllFields is mutually exclusive with other mask fields")
}
return &BuildMask{allFields: true, stepStatuses: stepStatuses}, nil
}
fm := bm.Fields
if len(fm.GetPaths()) == 0 {
fm = &defaultFieldMask
}
var cloned bool
structFilter := func(path string, structMask []*structmask.StructMask) (*structmask.Filter, error) {
if len(structMask) == 0 {
return nil, nil
}
// Implicitly include struct-valued fields when their masks are present.
// Make sure not to accidentally override the original FieldMask
// (in particular when it is &defaultFieldMask).
if !cloned {
fm = proto.Clone(fm).(*fieldmaskpb.FieldMask)
cloned = true
}
fm.Paths = append(fm.Paths, path)
return structmask.NewFilter(structMask)
}
// Parse struct masks. This also mutates `fm` to include corresponding fields.
in, err := structFilter("input.properties", bm.InputProperties)
if err != nil {
return nil, errors.Annotate(err, `bad "input_properties" struct mask`).Err()
}
out, err := structFilter("output.properties", bm.OutputProperties)
if err != nil {
return nil, errors.Annotate(err, `bad "output_properties" struct mask`).Err()
}
req, err := structFilter("infra.buildbucket.requested_properties", bm.RequestedProperties)
if err != nil {
return nil, errors.Annotate(err, `bad "requested_properties" struct mask`).Err()
}
// Construct the overall pb.Build mask.
var m *mask.Mask
if fm == &defaultFieldMask {
// An optimization for the common case, to avoid constructing mask.Mask all
// the time.
m = DefaultBuildMask.m
} else {
var err error
if m, err = mask.FromFieldMask(fm, &buildPrototype, false, false); err != nil {
return nil, err
}
}
// We want to support only field masks compatible with Go protobuf library.
// Note that "go.chromium.org/luci/common/proto/mask" implements a superset
// of this functionality. It also returns detailed errors. So we used it first
// to reject obviously invalid masks (e.g. referring to unknown fields) with
// nice error messages, and use a blunt IsValid check below to reject no
// longer supported non-protobuf compatible masks.
if !fm.IsValid(&buildPrototype) {
return nil, errors.Reason(
"the extended field mask syntax is no longer supported, " +
"use the standard one: " +
"https://pkg.go.dev/google.golang.org/protobuf/types/known/fieldmaskpb#FieldMask",
).Err()
}
return &BuildMask{
m: m,
in: in,
out: out,
req: req,
stepStatuses: stepStatuses,
}, nil
}
// newLegacyBuildMask constructs BuildMask from legacy `fields` field.
func newLegacyBuildMask(legacyPrefix string, fields *fieldmaskpb.FieldMask) (*BuildMask, error) {
if len(fields.GetPaths()) == 0 {
return DefaultBuildMask, nil
}
var m *mask.Mask
var err error
switch legacyPrefix {
case "":
m, err = mask.FromFieldMask(fields, &buildPrototype, false, false)
case "builds":
m, err = mask.FromFieldMask(fields, &searchBuildPrototype, false, false)
if err == nil {
m, err = m.Submask("builds.*")
}
default:
panic(fmt.Sprintf("unsupported legacy prefix %q", legacyPrefix))
}
if err != nil {
return nil, err
}
return &BuildMask{m: m}, nil
}
// HardcodedBuildMask returns a build mask with given fields.
//
// Panics if some of them are invalid. Intended to be used to initialize
// constants or in tests.
func HardcodedBuildMask(fields ...string) *BuildMask {
return &BuildMask{m: mask.MustFromReadMask(&buildPrototype, fields...)}
}
// BuildFieldsWithVisibility returns a list of Build fields that are visible
// with the specified level of read permission. For example, the following:
//
// BuildFieldsWithVisibility(pb.BuildFieldVisibility_BUILDS_GET_LIMITED_PERMISSION)
//
// will return a list of Build fields (including nested fields) that have been
// annotated with either of the following field options:
//
// [(visible_with) = BUILDS_GET_LIMITED_PERMISSION]
// [(visible_with) = BUILDS_LIST_PERMISSION]
//
// Note that visibility permissions are strictly ordered: if a user has the
// GetLimited permission, that implies they also have the List permission.
func BuildFieldsWithVisibility(visibility pb.BuildFieldVisibility) []string {
paths := make([]string, 0, 16)
findFieldPathsWithVisibility(buildPrototype.ProtoReflect().Descriptor(), []string{}, visibility, &paths)
return paths
}
func findFieldPathsWithVisibility(md protoreflect.MessageDescriptor, path []string, visibility pb.BuildFieldVisibility, outPaths *[]string) {
fields := md.Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
name := string(fd.Name())
opts := fd.Options().(*descriptorpb.FieldOptions)
fieldVisibility := proto.GetExtension(opts, pb.E_VisibleWith).(pb.BuildFieldVisibility)
if fieldVisibility.Number() >= visibility.Number() {
*outPaths = append(*outPaths, strings.Join(append(path, name), "."))
}
// Simplifying hack: since we currently only need recursion to depth 1,
// don't recurse into child messages if there is any path prefix.
// This allows us to avoid implementing cycle detection.
// If, in future, we want to give extended access to fields nested more
// than 1 message deep, this hack will need to be extended.
// Since field visibility fails closed, this isn't a security risk.
if len(path) > 0 {
continue
}
if fd.Kind() == protoreflect.MessageKind {
findFieldPathsWithVisibility(fd.Message(), append(path, name), visibility, outPaths)
}
}
}
// Includes returns true if the given field path is included in the mask
// (either partially or entirely), or the mask includes all fields.
//
// Panics if the fieldPath is invalid.
func (m *BuildMask) Includes(fieldPath string) bool {
if m.allFields {
return true
}
inc, err := m.m.Includes(fieldPath)
if err != nil {
panic(errors.Annotate(err, "bad field path %q", fieldPath).Err())
}
return inc != mask.Exclude
}
// Trim applies the mask to the build in-place.
func (m *BuildMask) Trim(b *pb.Build) error {
if err := m.m.Trim(b); err != nil {
return err
}
if m.in != nil && b.Input != nil {
b.Input.Properties = m.in.Apply(b.Input.Properties)
}
if m.out != nil && b.Output != nil {
b.Output.Properties = m.out.Apply(b.Output.Properties)
}
if m.req != nil && b.Infra != nil && b.Infra.Buildbucket != nil {
b.Infra.Buildbucket.RequestedProperties = m.req.Apply(b.Infra.Buildbucket.RequestedProperties)
}
if len(m.stepStatuses) > 0 && len(b.Steps) > 0 {
steps := make([]*pb.Step, 0, len(b.Steps))
for _, s := range b.Steps {
if _, ok := m.stepStatuses[s.Status]; ok {
steps = append(steps, s)
}
}
b.Steps = steps
}
return nil
}