409 lines
17 KiB
Go
409 lines
17 KiB
Go
package firmwareconformance
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
DeviceEvidenceSchema = "firmware-device-evidence-v1"
|
|
ReportSchema = "firmware-conformance-report-v1"
|
|
)
|
|
|
|
var digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
type CloudRollout struct {
|
|
ID string `json:"id"`
|
|
ReleaseID string `json:"release_id"`
|
|
Status string `json:"status"`
|
|
Assignments []CloudAssignment `json:"assignments"`
|
|
}
|
|
|
|
type CloudAssignment struct {
|
|
DeviceID string `json:"device_id"`
|
|
DeviceModel string `json:"device_model"`
|
|
PreviousVersion string `json:"previous_version"`
|
|
Status string `json:"status"`
|
|
Events []CloudEvent `json:"events"`
|
|
}
|
|
|
|
type CloudEvent struct {
|
|
ID string `json:"id"`
|
|
CommandID string `json:"command_id"`
|
|
CommandStatus string `json:"command_status"`
|
|
AssignmentStatus string `json:"assignment_status"`
|
|
Phase string `json:"phase"`
|
|
ProgressPercent *int `json:"progress_percent"`
|
|
ResultCode string `json:"result_code"`
|
|
ActiveSlot string `json:"active_slot"`
|
|
TargetSlot string `json:"target_slot"`
|
|
PreviousSlot string `json:"previous_slot"`
|
|
FailedSlot string `json:"failed_slot"`
|
|
BootAttemptID string `json:"boot_attempt_id"`
|
|
RollbackApplied *bool `json:"rollback_applied"`
|
|
RollbackVersion string `json:"rollback_version"`
|
|
InstalledVersion string `json:"installed_version"`
|
|
VerifiedSHA256 string `json:"verified_sha256"`
|
|
VerifiedSigningKeyID string `json:"verified_signing_key_id"`
|
|
ResultDigest string `json:"result_digest"`
|
|
ReportedAt time.Time `json:"reported_at"`
|
|
}
|
|
|
|
type DeviceEvidence struct {
|
|
SchemaVersion string `json:"schema_version"`
|
|
EvidenceSource string `json:"evidence_source"`
|
|
CandidateVersion string `json:"candidate_version"`
|
|
BuildDigest string `json:"build_digest"`
|
|
DeviceID string `json:"device_id"`
|
|
DeviceModel string `json:"device_model"`
|
|
HardwareRevision string `json:"hardware_revision"`
|
|
ReleaseID string `json:"release_id"`
|
|
CommandID string `json:"command_id"`
|
|
Scenario string `json:"scenario"`
|
|
BootloaderVersion string `json:"bootloader_version"`
|
|
PartitionTableSHA256 string `json:"partition_table_sha256"`
|
|
SecureBootEnabled bool `json:"secure_boot_enabled"`
|
|
ArtifactSHA256 string `json:"artifact_sha256"`
|
|
SigningKeyID string `json:"signing_key_id"`
|
|
InitialSlot string `json:"initial_slot"`
|
|
TargetSlot string `json:"target_slot"`
|
|
FinalSlot string `json:"final_slot"`
|
|
InitialVersion string `json:"initial_version"`
|
|
TargetVersion string `json:"target_version"`
|
|
FinalVersion string `json:"final_version"`
|
|
BootAttemptID string `json:"boot_attempt_id"`
|
|
CollectedAt time.Time `json:"collected_at"`
|
|
Events []DeviceEvent `json:"events"`
|
|
}
|
|
|
|
type DeviceEvent struct {
|
|
Phase string `json:"phase"`
|
|
OccurredAt time.Time `json:"occurred_at"`
|
|
ActiveSlot string `json:"active_slot,omitempty"`
|
|
TargetSlot string `json:"target_slot,omitempty"`
|
|
ResultCode string `json:"result_code,omitempty"`
|
|
BootAttemptID string `json:"boot_attempt_id,omitempty"`
|
|
}
|
|
|
|
type Check struct {
|
|
Code string `json:"code"`
|
|
Status string `json:"status"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
type Report struct {
|
|
SchemaVersion string `json:"schema_version"`
|
|
Result string `json:"result"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
CandidateVersion string `json:"candidate_version"`
|
|
BuildDigest string `json:"build_digest"`
|
|
EvidenceScope string `json:"evidence_scope"`
|
|
ExternalValidationRequired bool `json:"external_validation_required"`
|
|
DeviceID string `json:"device_id"`
|
|
ReleaseID string `json:"release_id"`
|
|
RolloutID string `json:"rollout_id"`
|
|
CommandID string `json:"command_id"`
|
|
Scenario string `json:"scenario"`
|
|
CloudEventCount int `json:"cloud_event_count"`
|
|
DeviceEventCount int `json:"device_event_count"`
|
|
InputSHA256 map[string]string `json:"input_sha256"`
|
|
Checks []Check `json:"checks"`
|
|
Disclaimer string `json:"disclaimer"`
|
|
}
|
|
|
|
func ParseCloud(content []byte) (CloudRollout, error) {
|
|
var envelope struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(content, &envelope); err != nil {
|
|
return CloudRollout{}, fmt.Errorf("decode cloud rollout: %w", err)
|
|
}
|
|
payload := content
|
|
if len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
|
payload = envelope.Data
|
|
}
|
|
var rollout CloudRollout
|
|
if err := json.Unmarshal(payload, &rollout); err != nil {
|
|
return CloudRollout{}, fmt.Errorf("decode cloud rollout data: %w", err)
|
|
}
|
|
if rollout.ID == "" || rollout.ReleaseID == "" {
|
|
return CloudRollout{}, errors.New("cloud rollout is missing id or release_id")
|
|
}
|
|
return rollout, nil
|
|
}
|
|
|
|
func ParseDeviceEvidence(content []byte) (DeviceEvidence, error) {
|
|
var evidence DeviceEvidence
|
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&evidence); err != nil {
|
|
return DeviceEvidence{}, fmt.Errorf("decode device evidence: %w", err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return DeviceEvidence{}, errors.New("device evidence contains trailing JSON data")
|
|
}
|
|
return evidence, nil
|
|
}
|
|
|
|
// ParseReport reads the exact report format emitted by cmd/firmware-conformance.
|
|
// Strict decoding prevents an unreviewed producer extension from being treated as
|
|
// release evidence by downstream packaging tools.
|
|
func ParseReport(content []byte) (Report, error) {
|
|
var report Report
|
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&report); err != nil {
|
|
return Report{}, fmt.Errorf("decode firmware conformance report: %w", err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return Report{}, errors.New("firmware conformance report contains trailing JSON data")
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func Evaluate(cloud CloudRollout, device DeviceEvidence, generatedAt time.Time) Report {
|
|
report := Report{
|
|
SchemaVersion: ReportSchema, Result: "PASS", GeneratedAt: generatedAt.UTC(), CandidateVersion: device.CandidateVersion,
|
|
BuildDigest: device.BuildDigest, EvidenceScope: "PROTOCOL_SIMULATION", ExternalValidationRequired: true,
|
|
DeviceID: device.DeviceID, ReleaseID: device.ReleaseID, RolloutID: cloud.ID, CommandID: device.CommandID, Scenario: device.Scenario,
|
|
CloudEventCount: 0, DeviceEventCount: len(device.Events), InputSHA256: map[string]string{}, Checks: []Check{},
|
|
Disclaimer: "一致性通过只证明所提供设备证据与云端固件时间线相互匹配;不替代真实Bootloader、闪存掉电一致性、硬件安全或生产发布验收。",
|
|
}
|
|
if device.EvidenceSource == "HARDWARE" {
|
|
report.EvidenceScope = "HARDWARE_CAPTURE"
|
|
}
|
|
add := func(code string, ok bool, detail string) {
|
|
status := "PASS"
|
|
if !ok {
|
|
status = "FAIL"
|
|
report.Result = "FAIL"
|
|
}
|
|
report.Checks = append(report.Checks, Check{Code: code, Status: status, Detail: detail})
|
|
}
|
|
|
|
add("DEVICE_EVIDENCE_SCHEMA", device.SchemaVersion == DeviceEvidenceSchema, "设备证据必须使用"+DeviceEvidenceSchema)
|
|
add("EVIDENCE_SOURCE", device.EvidenceSource == "SIMULATOR" || device.EvidenceSource == "HARDWARE", "证据来源必须明确为SIMULATOR或HARDWARE")
|
|
buildOK := strings.TrimSpace(device.CandidateVersion) != "" && strings.HasPrefix(device.BuildDigest, "sha256:") && digestPattern.MatchString(strings.TrimPrefix(device.BuildDigest, "sha256:"))
|
|
add("CANDIDATE_IDENTITY", buildOK, "候选版本和sha256构建摘要必须完整")
|
|
identityOK := validUUID(device.DeviceID) && validUUID(device.ReleaseID) && validUUID(device.CommandID) && device.DeviceModel != "" && device.HardwareRevision != ""
|
|
add("DEVICE_IDENTITY", identityOK, "设备、发布和命令必须使用公开UUID,并提供型号与硬件版本")
|
|
platformOK := device.BootloaderVersion != "" && digestPattern.MatchString(device.PartitionTableSHA256)
|
|
add("DEVICE_PLATFORM_METADATA", platformOK, fmt.Sprintf("Bootloader=%s,安全启动=%t,分区表摘要必须为SHA-256", device.BootloaderVersion, device.SecureBootEnabled))
|
|
artifactOK := digestPattern.MatchString(device.ArtifactSHA256) && device.SigningKeyID != ""
|
|
add("ARTIFACT_IDENTITY", artifactOK, "设备证据必须绑定完整制品摘要和签名密钥编号")
|
|
|
|
assignment, found := findAssignment(cloud.Assignments, device.DeviceID)
|
|
add("CLOUD_ASSIGNMENT", found, "云端批次必须包含目标设备且只能匹配一条分配")
|
|
add("RELEASE_BINDING", cloud.ReleaseID == device.ReleaseID, "设备证据发布编号必须与云端批次一致")
|
|
if found {
|
|
report.CloudEventCount = len(assignment.Events)
|
|
}
|
|
cloudArtifactBound := false
|
|
if found {
|
|
for _, event := range assignment.Events {
|
|
if event.VerifiedSHA256 == device.ArtifactSHA256 && event.VerifiedSigningKeyID == device.SigningKeyID {
|
|
cloudArtifactBound = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
add("CLOUD_ARTIFACT_BINDING", cloudArtifactBound, "云端阶段时间线必须包含与设备一致的已验证摘要和签名密钥")
|
|
commandBound := found && len(assignment.Events) > 0
|
|
if commandBound {
|
|
for _, event := range assignment.Events {
|
|
if event.CommandID != device.CommandID {
|
|
commandBound = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
add("COMMAND_BINDING", commandBound, "全部云端阶段事件必须绑定同一设备命令")
|
|
cloudTimelineOK := found && validateCloudTimeline(assignment.Events)
|
|
add("CLOUD_TIMELINE_INTEGRITY", cloudTimelineOK, "事件编号、结果摘要哈希、接收时间和顺序必须有效")
|
|
deviceTimelineOK := validateDeviceTimeline(device.Events, device.CollectedAt)
|
|
add("DEVICE_TIMELINE_INTEGRITY", deviceTimelineOK, "设备事件必须按时间升序且不晚于证据采集时间")
|
|
|
|
terminalPhase, scenarioOK := scenarioTerminalPhase(device.Scenario)
|
|
add("SCENARIO_SUPPORTED", scenarioOK, "场景必须为AB_SUCCESS、AB_ROLLBACK、POWER_LOSS_AFTER_STAGE或POWER_LOSS_DURING_BOOT")
|
|
required := []string{"staging", "reboot_pending", "boot_verifying", terminalPhase}
|
|
cloudPhasesOK := scenarioOK && found && containsPhaseSequence(cloudPhases(assignment.Events), required)
|
|
deviceRequired := append([]string{}, required...)
|
|
if device.Scenario == "POWER_LOSS_AFTER_STAGE" {
|
|
deviceRequired = []string{"staging", "power_loss_after_stage", "reboot_pending", "boot_verifying", terminalPhase}
|
|
}
|
|
if device.Scenario == "POWER_LOSS_DURING_BOOT" {
|
|
deviceRequired = []string{"staging", "reboot_pending", "boot_verifying", "power_loss_during_boot", "boot_verifying", terminalPhase}
|
|
}
|
|
devicePhasesOK := scenarioOK && containsPhaseSequence(devicePhases(device.Events), deviceRequired)
|
|
add("CLOUD_PHASE_SEQUENCE", cloudPhasesOK, "云端必须按顺序留存写入、待重启、健康确认和终态")
|
|
add("DEVICE_PHASE_SEQUENCE", devicePhasesOK, "设备侧必须提供与场景一致的本地阶段顺序")
|
|
|
|
bootAttemptOK := found && device.BootAttemptID != ""
|
|
if bootAttemptOK {
|
|
seen := false
|
|
for _, event := range assignment.Events {
|
|
if event.BootAttemptID == "" {
|
|
continue
|
|
}
|
|
seen = true
|
|
if event.BootAttemptID != device.BootAttemptID {
|
|
bootAttemptOK = false
|
|
}
|
|
}
|
|
bootAttemptOK = bootAttemptOK && seen
|
|
}
|
|
add("BOOT_ATTEMPT_BINDING", bootAttemptOK, "设备和云端必须引用同一启动尝试编号")
|
|
|
|
phaseTimeOK, maxSkew := phaseTimeCorrelation(assignment.Events, device.Events)
|
|
add("PHASE_TIME_CORRELATION", found && phaseTimeOK, fmt.Sprintf("同名设备/云端阶段最大时间差为%s,允许10分钟", maxSkew.Round(time.Second)))
|
|
|
|
slotOK := device.InitialSlot != "" && device.TargetSlot != "" && device.InitialSlot != device.TargetSlot
|
|
versionOK := device.InitialVersion != "" && device.TargetVersion != "" && device.FinalVersion != ""
|
|
outcomeOK := false
|
|
if found && scenarioOK && len(assignment.Events) > 0 {
|
|
finalEvent := assignment.Events[len(assignment.Events)-1]
|
|
if terminalPhase == "rolled_back" {
|
|
outcomeOK = assignment.Status == "FAILED" && finalEvent.CommandStatus == "FAILED" && finalEvent.Phase == "rolled_back" && finalEvent.RollbackApplied != nil && *finalEvent.RollbackApplied && finalEvent.RollbackVersion == device.InitialVersion && device.FinalSlot == device.InitialSlot && device.FinalVersion == device.InitialVersion
|
|
slotOK = slotOK && device.FinalSlot == device.InitialSlot
|
|
versionOK = versionOK && device.FinalVersion == device.InitialVersion
|
|
} else {
|
|
outcomeOK = assignment.Status == "CONFIRMED" && finalEvent.CommandStatus == "SUCCEEDED" && finalEvent.Phase == "confirmed" && finalEvent.InstalledVersion == device.TargetVersion && finalEvent.VerifiedSHA256 == device.ArtifactSHA256 && finalEvent.VerifiedSigningKeyID == device.SigningKeyID && device.FinalSlot == device.TargetSlot && device.FinalVersion == device.TargetVersion
|
|
slotOK = slotOK && device.FinalSlot == device.TargetSlot
|
|
versionOK = versionOK && device.FinalVersion == device.TargetVersion
|
|
}
|
|
}
|
|
add("SLOT_TRANSITION", slotOK, "目标分区必须不同于初始分区,成功或回退后的活动分区必须符合场景")
|
|
add("VERSION_TRANSITION", versionOK, "最终版本必须与成功升级或自动回退结果一致")
|
|
add("TERMINAL_OUTCOME", outcomeOK, "云端命令、分配终态、设备分区和最终版本必须一致")
|
|
return report
|
|
}
|
|
|
|
func SHA256(content []byte) string {
|
|
digest := sha256.Sum256(content)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func validUUID(value string) bool {
|
|
_, err := uuid.Parse(value)
|
|
return err == nil
|
|
}
|
|
|
|
func findAssignment(items []CloudAssignment, deviceID string) (CloudAssignment, bool) {
|
|
var result CloudAssignment
|
|
count := 0
|
|
for _, item := range items {
|
|
if item.DeviceID == deviceID {
|
|
result = item
|
|
count++
|
|
}
|
|
}
|
|
return result, count == 1
|
|
}
|
|
|
|
func validateCloudTimeline(events []CloudEvent) bool {
|
|
if len(events) == 0 {
|
|
return false
|
|
}
|
|
for index, event := range events {
|
|
if !validUUID(event.ID) || !validUUID(event.CommandID) || !digestPattern.MatchString(event.ResultDigest) || event.ReportedAt.IsZero() {
|
|
return false
|
|
}
|
|
if index > 0 && event.ReportedAt.Before(events[index-1].ReportedAt) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateDeviceTimeline(events []DeviceEvent, collectedAt time.Time) bool {
|
|
if len(events) == 0 || collectedAt.IsZero() {
|
|
return false
|
|
}
|
|
for index, event := range events {
|
|
if event.Phase == "" || event.OccurredAt.IsZero() || event.OccurredAt.After(collectedAt) {
|
|
return false
|
|
}
|
|
if index > 0 && event.OccurredAt.Before(events[index-1].OccurredAt) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func scenarioTerminalPhase(scenario string) (string, bool) {
|
|
switch scenario {
|
|
case "AB_SUCCESS", "POWER_LOSS_AFTER_STAGE", "POWER_LOSS_DURING_BOOT":
|
|
return "confirmed", true
|
|
case "AB_ROLLBACK":
|
|
return "rolled_back", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func cloudPhases(events []CloudEvent) []string {
|
|
result := make([]string, 0, len(events))
|
|
for _, event := range events {
|
|
result = append(result, event.Phase)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func devicePhases(events []DeviceEvent) []string {
|
|
result := make([]string, 0, len(events))
|
|
for _, event := range events {
|
|
result = append(result, event.Phase)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func containsPhaseSequence(actual, required []string) bool {
|
|
position := 0
|
|
for _, value := range actual {
|
|
if position < len(required) && value == required[position] {
|
|
position++
|
|
}
|
|
}
|
|
return position == len(required)
|
|
}
|
|
|
|
func phaseTimeCorrelation(cloud []CloudEvent, device []DeviceEvent) (bool, time.Duration) {
|
|
maximum := time.Duration(0)
|
|
matched := 0
|
|
for _, local := range device {
|
|
closest := time.Duration(1<<63 - 1)
|
|
for _, remote := range cloud {
|
|
if remote.Phase != local.Phase {
|
|
continue
|
|
}
|
|
difference := remote.ReportedAt.Sub(local.OccurredAt)
|
|
if difference < 0 {
|
|
difference = -difference
|
|
}
|
|
if difference < closest {
|
|
closest = difference
|
|
}
|
|
}
|
|
if closest == time.Duration(1<<63-1) {
|
|
continue
|
|
}
|
|
matched++
|
|
if closest > maximum {
|
|
maximum = closest
|
|
}
|
|
}
|
|
return matched >= 4 && maximum <= 10*time.Minute, maximum
|
|
}
|