blob: d3a3531e4d82c505d9824f4afb6e6f9db898d0e4 [file] [log] [blame]
// Copyright 2018 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 digests holds types used by selfupdate mechanism to pin client
// hashes.
package digests
import (
"bufio"
"fmt"
"io"
"sort"
"strings"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/iotools"
api "go.chromium.org/luci/cipd/api/cipd/v1"
"go.chromium.org/luci/cipd/common"
"go.chromium.org/luci/cipd/common/cipderr"
)
// ClientDigestsFile holds a mapping "platform => hash of the client binary for
// given platform", for some particular version of the CIPD client (provided
// elsewhere).
//
// It is used to "lock" the client binary during updates, much in a same way
// $ResolvedVersions file is used to "lock" hashes of the packages. Unlike
// the latter, the client version file holds digests of the CIPD client binary
// itself, not a CIPD package.
//
// This file is parsed by 'cipd selfupdate' and also by various bootstrap
// scripts that fetch the initial copy of the client. For that reason the format
// is relatively simple:
//
// """
// # Comment.
//
// <platform> <hash algo> <hex digest>
// ...
// """
//
// Where <platform> is one of ${platform} values (e.g. "linux-amd64"), and
// <hash algo> is one of stringified case-insensitive HashAlgo enum values from
// api/cas.proto (e.g. "sha256").
//
// Comments are allowed and must occupy their own line. Empty new lines are
// skipped. All non-empty lines have 3 fields (with any number of whitespace
// characters between fields).
//
// Order of lines is not significant.
type ClientDigestsFile struct {
entries []clientDigestEntry
}
type clientDigestEntry struct {
plat string
ref *api.ObjectRef
}
// AddClientRef appends the client's digest given as ObjectRef.
//
// Returns an error (platform, hash algo) combination has already been added or
// the hash is unrecognized.
func (d *ClientDigestsFile) AddClientRef(plat string, ref *api.ObjectRef) error {
if err := common.ValidateObjectRef(ref, common.KnownHash); err != nil {
return err
}
for _, e := range d.entries {
if e.plat == plat && e.ref.HashAlgo == ref.HashAlgo {
return errors.Reason("%s hash for %s has already been added", ref.HashAlgo, plat).Tag(cipderr.BadArgument).Err()
}
}
d.entries = append(d.entries, clientDigestEntry{plat, ref})
return nil
}
// ClientRef returns an expected client ObjectRef for the given platform.
//
// Returns the best hash (higher algo number) or nil if there are no digests
// for this platform at all.
func (d *ClientDigestsFile) ClientRef(plat string) (ref *api.ObjectRef) {
for _, e := range d.entries {
if e.plat == plat && (ref == nil || e.ref.HashAlgo > ref.HashAlgo) {
ref = e.ref
}
}
return
}
// Contains returns true if the given ref is among refs for the given platform.
//
// Compares 'ref' to all hashes corresponding to 'plat', not only the best one.
func (d *ClientDigestsFile) Contains(plat string, ref *api.ObjectRef) bool {
for _, e := range d.entries {
if e.plat == plat && proto.Equal(ref, e.ref) {
return true
}
}
return false
}
// Sort orders the entries by (platform, -hashAlgo).
func (d *ClientDigestsFile) Sort() {
sort.Slice(d.entries, func(i, j int) bool {
l, r := d.entries[i], d.entries[j]
if l.plat != r.plat {
return l.plat < r.plat
}
return l.ref.HashAlgo > r.ref.HashAlgo // more recent algos first
})
}
// Equal returns true if files have same entries in same order.
func (d *ClientDigestsFile) Equal(a *ClientDigestsFile) bool {
if len(d.entries) != len(a.entries) {
return false
}
for i, l := range d.entries {
if r := a.entries[i]; l.plat != r.plat || !proto.Equal(l.ref, r.ref) {
return false
}
}
return true
}
// Serialize writes the ClientDigestsFile to an io.Writer.
//
// 'version' and 'versionFile' are used to construct a meaningful comment
// footer.
func (d *ClientDigestsFile) Serialize(w io.Writer, version, versionFile string) error {
_, err := iotools.WriteTracker(w, func(w io.Writer) error {
fmt.Fprintf(w, "# This file was generated by\n")
fmt.Fprintf(w, "#\n")
fmt.Fprintf(w, "# cipd selfupdate-roll -version-file %s \\\n", versionFile)
fmt.Fprintf(w, "# -version %s\n", version)
fmt.Fprintf(w, "#\n")
fmt.Fprintf(w, "# Do not modify manually. All changes will be overwritten.\n")
fmt.Fprintf(w, "# Use 'cipd selfupdate-roll ...' to modify.\n\n")
// Align fields nicely.
max := []int{0, 0}
for _, e := range d.entries {
if l := len(e.plat); l > max[0] {
max[0] = l
}
if l := len(e.ref.HashAlgo.String()); l > max[1] {
max[1] = l
}
}
for _, e := range d.entries {
algo := strings.ToLower(e.ref.HashAlgo.String())
fmt.Fprintf(w, "%-*s%-*s%s\n",
max[0]+2, e.plat,
max[1]+2, algo,
e.ref.HexDigest,
)
}
return nil
})
if err != nil {
return errors.Annotate(err, "failed to write client digests file").Tag(cipderr.IO).Err()
}
return nil
}
// ParseClientDigestsFile parses previously serialized client digests file.
//
// Unrecognized algorithms are silently skipped, to be compatible with files
// generated by the future versions of CIPD that may use different algorithms.
func ParseClientDigestsFile(r io.Reader) (*ClientDigestsFile, error) {
res := &ClientDigestsFile{}
lineNo := 0
makeError := func(fmtStr string, args ...any) error {
args = append([]any{lineNo}, args...)
return errors.Reason("failed to parse client digests file (line %d): "+fmtStr, args...).Tag(cipderr.BadArgument).Err()
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' {
continue
}
tokens := strings.Fields(line)
if len(tokens) != 3 {
return nil, makeError("each line must have format \"<platform> <algo> <digest>\"")
}
algoIdx := api.HashAlgo_value[strings.ToUpper(tokens[1])]
if algoIdx == 0 {
continue // skip unknown algorithms
}
ref := &api.ObjectRef{
HashAlgo: api.HashAlgo(algoIdx),
HexDigest: tokens[2],
}
if err := common.ValidateObjectRef(ref, common.KnownHash); err != nil {
return nil, makeError("%s", err)
}
if err := res.AddClientRef(tokens[0], ref); err != nil {
return nil, makeError("%s", err)
}
}
if err := scanner.Err(); err != nil {
return nil, errors.Annotate(err, "failed to read client digests file").Tag(cipderr.IO).Err()
}
return res, nil
}