blob: fba369f15837e320a7b28957298b48e5f03b54ba [file] [log] [blame]
// Copyright 2020 The Chromium OS 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 platform
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/proto"
"github.com/google/go-cmp/cmp"
"golang.org/x/crypto/ssh"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/testing/protocmp"
hvpb "chromiumos/hardware_verifier"
rppb "chromiumos/system_api/runtime_probe_proto"
"chromiumos/tast/dut"
"chromiumos/tast/errors"
"chromiumos/tast/testing"
)
type stringSet map[string]struct{}
type requiredFieldSet map[string]*stringSet
type sortableMessage []proto.Message
func (set *stringSet) Contains(s string) bool {
_, exists := (*set)[s]
return exists
}
func (set *stringSet) Add(s string) {
(*set)[s] = struct{}{}
}
func (set *stringSet) String() string {
keys := make([]string, 0, len(*set))
for k := range *set {
keys = append(keys, k)
}
return fmt.Sprintf("%v", keys)
}
func (s sortableMessage) Len() int {
return len(s)
}
func (s sortableMessage) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s sortableMessage) Less(i, j int) bool {
return fmt.Sprintf("%v", s[i]) < fmt.Sprintf("%v", s[j])
}
func (s sortableMessage) String() string {
var buffer bytes.Buffer
buffer.WriteString("[\n")
for i := 0; i < len(s); i++ {
buffer.WriteString(fmt.Sprintf("%v\n", s[i]))
}
buffer.WriteString("]")
return buffer.String()
}
func init() {
testing.AddTest(&testing.Test{
Func: CrosHardwareVerifier,
Desc: "Test Hardware Verifier functionality",
Contacts: []string{"ckclark@chromium.org", "chromeos-runtime-probe@google.com"},
SoftwareDeps: []string{"reboot", "racc"},
Attr: []string{"group:runtime_probe"},
})
}
// CrosHardwareVerifier checks if component info are identical in three
// different sources: hardware_verifier, GenericDeviceInfo in verification
// report, and probe result of runtime_probe.
func CrosHardwareVerifier(ctx context.Context, s *testing.State) {
fieldsMapping, err := requiredFields(ctx, s)
if err != nil {
s.Fatal("Cannot get GenericComponentValueAllowlists: ", err)
}
s.Log("ComponentValueAllowlists:", fieldsMapping)
err = waitServiceState(ctx, s.DUT(), s, "hardware_verifier", "stop/waiting")
if err != nil {
s.Fatal("Service hardware_verifier timed out: ", err)
}
messagesFromProbe, err := probe(ctx, s.DUT(), fieldsMapping)
if err != nil {
s.Fatal("Cannot get probe results: ", err)
}
s.Log("MessageFromRuntimeProbe:", messagesFromProbe)
messagesFromVerifier, err := hwVerify(ctx, s.DUT(), fieldsMapping)
if err != nil {
s.Fatal("Cannot get result of hardware_verifier: ", err)
}
s.Log("MessageFromHwVerifier:", messagesFromVerifier)
messagesFromReport, err := report(ctx, s, fieldsMapping)
if err != nil {
s.Fatal("Cannot get result from report: ", err)
}
s.Log("MessageFromFile:", messagesFromReport)
if diff := cmp.Diff(messagesFromReport, messagesFromVerifier, protocmp.Transform()); diff != "" {
s.Log("Message mismatch (-report +hwVerifier):")
s.Log(diff)
s.Error("Message mismatch between report and hwVerifier (see logs for diff)")
}
if diff := cmp.Diff(messagesFromReport, messagesFromProbe, protocmp.Transform()); diff != "" {
s.Log("Message mismatch (-report +probe):")
s.Log(diff)
s.Error("Message mismatch between report and probe (see logs for diff)")
}
}
// requiredFields returns the allowed fields defined in either
// /usr/local/etc/hardware_verifier/hw_verification_spec.prototxt or
// /etc/hardware_verifier/hw_verification_spec.prototxt. Probed results from
// runtime_probe will remove fields that are not allowed so that it would
// be identical with other sources.
func requiredFields(ctx context.Context, s *testing.State) (requiredFieldSet, error) {
const verificationSpecRelPath = "etc/hardware_verifier/hw_verification_spec.prototxt"
dut := s.DUT()
fieldsMapping := make(requiredFieldSet)
// We assume that cros_debug is always enabled on testing DUTs.
verificationSpecPath := "/usr/local/" + verificationSpecRelPath
output, err := dut.Conn().CommandContext(ctx, "cat", verificationSpecPath).Output()
if err != nil {
verificationSpecPath = "/" + verificationSpecRelPath
output, err = dut.Conn().CommandContext(ctx, "cat", verificationSpecPath).Output()
if err != nil {
return nil, err
}
}
s.Log("Verification spec path: ", verificationSpecPath)
message := &hvpb.HwVerificationSpec{}
if err := proto.UnmarshalText(string(output), message); err != nil {
return nil, err
}
for _, allowlist := range message.GenericComponentValueAllowlists {
category := allowlist.ComponentCategory.String()
for _, field := range allowlist.FieldNames {
if m, ok := fieldsMapping[category]; ok {
m.Add(field)
} else {
fieldsMapping[category] = &stringSet{field: struct{}{}}
}
}
}
return fieldsMapping, nil
}
// decodeResult will return decoded binary of hex-encoded result from
// dbus-send, also it trims the prefix, suffix, and all space characters.
// For reference, the output format of dbus-send is:
// array of bytes [
// 1a 6f 0a ...
// ]
func decodeResult(result string) []byte {
result = strings.TrimSuffix(strings.TrimPrefix(result, " array of bytes ["), "]\n")
result = strings.NewReplacer(" ", "", "\n", "").Replace(result)
resultBytes := []byte(result)
decoded := make([]byte, hex.DecodedLen(len(resultBytes)))
hex.Decode(decoded, resultBytes)
return decoded
}
// trimFields trims fields not defined in fieldsMapping and return all
// components in a slice. The approach is to enumerate the fields by
// protoreflect library and check if the names are mentioned in the
// fieldsMapping.
func trimFields(message *rppb.ProbeResult, fieldsMapping requiredFieldSet) (sortableMessage, error) {
var probeResults sortableMessage
messagePr := message.ProtoReflect()
messageDesc := messagePr.Descriptor()
messageFieldDescs := messageDesc.Fields()
for category, allowlist := range fieldsMapping {
componentListDesc := messageFieldDescs.ByName(protoreflect.Name(category))
if componentListDesc.Message() == nil {
return nil, errors.New("componentList is not Message type")
}
if !componentListDesc.IsList() {
return nil, errors.New("componentList is not a list")
}
components := messagePr.Get(componentListDesc).List()
for i := 0; i < components.Len(); i++ {
component := components.Get(i).Message()
compFieldsDesc := component.Descriptor().Fields()
compNameDesc := compFieldsDesc.ByName(protoreflect.Name("name"))
if compNameDesc.Kind() != protoreflect.StringKind {
return nil, errors.New("compName is not a string")
}
compName := component.Get(compNameDesc).String()
if compName == "generic" {
compValuesDesc := compFieldsDesc.ByName(protoreflect.Name("values"))
if compValuesDesc.Message() == nil {
return nil, errors.New("compValues is not a message")
}
values := component.Get(compValuesDesc).Message()
valuesFieldsDesc := values.Descriptor().Fields()
for i := 0; i < valuesFieldsDesc.Len(); i++ {
valuesFieldDesc := valuesFieldsDesc.Get(i)
sp := strings.Split(string(valuesFieldDesc.FullName()), ".")
fieldName := sp[len(sp)-1]
if !allowlist.Contains(fieldName) {
values.Set(valuesFieldDesc, valuesFieldDesc.Default())
}
}
probeResults = append(probeResults, values.Interface().(proto.Message))
}
}
}
return probeResults, nil
}
// probe returns sortableMessage which collects components probed by generic
// probe statement. Also it only keep fields defined in fieldsMapping. The
// approach to get the probe result is similar to
// https://chromium.googlesource.com/chromiumos/platform2/+/HEAD/runtime_probe/README.md#via-d_bus-call
func probe(ctx context.Context, dut *dut.DUT, fieldsMapping requiredFieldSet) (sortableMessage, error) {
req := &rppb.ProbeRequest{ProbeDefaultCategory: true}
b, err := proto.Marshal(req)
if err != nil {
return nil, err
}
decimals := make([]string, len(b))
for i, v := range b {
decimals[i] = strconv.Itoa(int(v))
}
bytesLiteral := strings.Join(decimals, ",")
args := []string{"-u", "chronos", "dbus-send", "--system",
"--print-reply=literal", "--type=method_call",
"--dest=org.chromium.RuntimeProbe", "/org/chromium/RuntimeProbe",
"org.chromium.RuntimeProbe.ProbeCategories",
"array:byte:" + bytesLiteral,
}
output, err := dut.Conn().CommandContext(ctx, "sudo", args...).Output()
if err != nil {
return nil, err
}
hexEncodedResult := string(output)
binaryResult := decodeResult(hexEncodedResult)
message := &rppb.ProbeResult{}
if err := proto.Unmarshal(binaryResult, message); err != nil {
return nil, err
}
probeResults, err := trimFields(message, fieldsMapping)
if err != nil {
return nil, err
}
sort.Sort(probeResults)
return probeResults, nil
}
// collectFields returns an array of {category}.Fields messages from
// GenericDeviceInfo defined in hardware_verifier.proto. Since we could not
// guarantee the order of these messages of probe results, we just sort them by
// .String() function for comparison.
func collectFields(deviceInfo *hvpb.HwVerificationReport_GenericDeviceInfo, fieldsMapping requiredFieldSet) (sortableMessage, error) {
var messageList sortableMessage
deviceInfoPr := deviceInfo.ProtoReflect()
deviceInfoDesc := deviceInfoPr.Descriptor()
for category := range fieldsMapping {
fieldsListDesc := deviceInfoDesc.Fields().ByName(protoreflect.Name(category))
if fieldsListDesc.Message() == nil {
return nil, errors.New("fieldsList is not Message type")
}
if !fieldsListDesc.IsList() {
return nil, errors.New("fieldsList is not list")
}
fieldsList := deviceInfoPr.Get(fieldsListDesc).List()
for i := 0; i < fieldsList.Len(); i++ {
fields := fieldsList.Get(i).Message()
messageList = append(messageList, fields.Interface().(proto.Message))
}
}
sort.Sort(messageList)
return messageList, nil
}
// hwVerify returns an array of {category}.Fields messages from output
// result of hardware_verifier binary. This function is called before the
// reboot in order to verify the consistency of the execution in the init
// script /etc/init/hardware-verifier.conf.
func hwVerify(ctx context.Context, dut *dut.DUT, fieldsMapping requiredFieldSet) (sortableMessage, error) {
args := []string{"-u", "hardware_verifier", "hardware_verifier", "--pii"}
output, err := dut.Conn().CommandContext(ctx, "sudo", args...).Output()
if err != nil {
exitError, isExitError := err.(*ssh.ExitError)
// For unqualified hardware components, hardware_verifier would exit with
// status 1 and it is expected.
if !isExitError || exitError.ExitStatus() != 1 {
return nil, err
}
}
message := &hvpb.HwVerificationReport{}
if err := proto.Unmarshal(output, message); err != nil {
return nil, err
}
return collectFields(message.GetGenericDeviceInfo(), fieldsMapping)
}
// report returns an array of {category}.Fields messages from the
// hardware_verifier.result dumped at boot. We remove the report file and then
// reboot and try to poll it. Once we have the report, we will check the
// format, decode the proto message, and collect the fields required.
func report(ctx context.Context, s *testing.State, fieldsMapping requiredFieldSet) (sortableMessage, error) {
const (
resultFileDir = "/var/cache"
resultFileName = "hardware_verifier.result"
qualifcationStatusHeader = "[Component Qualification Status]\n"
genericComponentInfoHeader = "[Generic Device Info]\n"
)
resultFilePath := filepath.Join(resultFileDir, resultFileName)
outPath := filepath.Join(s.OutDir(), resultFileName)
d := s.DUT()
s.Log("Remove result file")
if err := d.Conn().CommandContext(ctx, "rm", "-f", resultFilePath).Run(); err != nil {
return nil, errors.Wrap(err, "cannot delete file")
}
s.Log("Reboot to trigger a dump of result file from hardware_verifier")
if err := d.Reboot(ctx); err != nil {
return nil, errors.Wrap(err, "failed to reboot DUT")
}
// TODO(crbug/1097710): Remove this check when this is the default behavior.
if err := waitServiceState(ctx, d, s, "system-services", "start/running"); err != nil {
return nil, err
}
if err := pollResultFile(ctx, d, s, resultFilePath, outPath); err != nil {
return nil, err
}
s.Log("Got HwVerifier report at:", outPath)
bytes, err := ioutil.ReadFile(outPath)
if err != nil {
return nil, err
}
fileContent := string(bytes)
if !strings.HasPrefix(fileContent, qualifcationStatusHeader) {
return nil, errors.New("result file format error, no qualification status")
}
fileContent = strings.TrimPrefix(fileContent, qualifcationStatusHeader)
splits := strings.Split(fileContent, genericComponentInfoHeader)
if len(splits) != 2 {
return nil, errors.New("result file format error, no generic device info")
}
// We just check if it's a valid qualification status in json format.
qualificationStatus := splits[0]
var jsonResult map[string]interface{}
if err := json.Unmarshal([]byte(qualificationStatus), &jsonResult); err != nil {
return nil, errors.New("content is not valid JSON format")
}
// Check if "isCompliant" field exists and is a boolean value since we set
// always_print_primitive_fields true while dumping.
if val, ok := jsonResult["isCompliant"]; !ok {
return nil, errors.New("isCompliant does not exist in qualifcation status section")
} else if _, isBool := val.(bool); !isBool {
return nil, errors.New("isCompliant should be a boolean value")
}
if err := jsonpb.UnmarshalString(qualificationStatus, &hvpb.HwVerificationReport{}); err != nil {
return nil, errors.New("cannot decode qualification status section to a proto message")
}
resultText := splits[1]
message := &hvpb.HwVerificationReport_GenericDeviceInfo{}
if err := proto.UnmarshalText(resultText, message); err != nil {
return nil, errors.Wrap(err, "cannot unmarshal")
}
return collectFields(message, fieldsMapping)
}
// pollResultFile polls the result file, and then copies it to local.
func pollResultFile(ctx context.Context, d *dut.DUT, s *testing.State, resultFilePath, outPath string) error {
const (
pollInterval = time.Second
pollTimeout = 2 * time.Minute
)
// Current implementation is to sleep 50s before dumping result file.
// See https://crrev.com/c/2100362 for more context.
s.Log("Polling result file and copy it to local")
if err := testing.Poll(ctx, func(ctx context.Context) error {
if err := d.GetFile(ctx, resultFilePath, outPath); err != nil {
return err
}
return nil
}, &testing.PollOptions{Interval: pollInterval, Timeout: pollTimeout}); err != nil {
return errors.Wrap(err, "result file does not exist")
}
return nil
}
// waitServiceState waits for a service to be specific state.
func waitServiceState(ctx context.Context, d *dut.DUT, s *testing.State, service, state string) error {
const (
pollInterval = time.Second
pollTimeout = 2 * time.Minute
)
s.Logf("Wait for %s to be %s state", service, state)
if err := testing.Poll(ctx, func(ctx context.Context) error {
output, err := d.Conn().CommandContext(ctx, "initctl", "status", service).Output()
if err != nil {
return err
}
if strings.Contains(string(output), state) {
return nil
}
return errors.Errorf("%s is not %s state", service, state)
}, &testing.PollOptions{Interval: pollInterval, Timeout: pollTimeout}); err != nil {
return err
}
return nil
}