|
|
|
|
package update
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"archive/tar"
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/ed25519"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
testProduct = "tera-core-x48-pro"
|
|
|
|
|
testProfile = "fet3568j-c"
|
|
|
|
|
testKeyID = "test-lab-key"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestManagerStagesAndValidatesSignedPackage(t *testing.T) {
|
|
|
|
|
manager, privateKey := newTestManager(t)
|
|
|
|
|
packageBytes := signedPackage(t, privateKey, testProfile, []byte("application payload"))
|
|
|
|
|
status, err := manager.Upload(context.Background(), UploadRequest{Filename: "release.bin", ContentLength: int64(len(packageBytes)), Actor: "admin", Body: bytes.NewReader(packageBytes)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Upload: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if status.State != StateValidated || status.Manifest == nil || status.Manifest.Version != "1.1.0" || status.SignerKeyID != testKeyID {
|
|
|
|
|
t.Fatalf("unexpected validated status: %+v", status)
|
|
|
|
|
}
|
|
|
|
|
if status.ApplyEnabled || status.ApplyDisabledReason == "" || status.RecoveryInstructions == "" {
|
|
|
|
|
t.Fatalf("unsafe apply state: %+v", status)
|
|
|
|
|
}
|
|
|
|
|
if status.Capacity.UpdateReserved != 0 {
|
|
|
|
|
t.Fatalf("completed upload retained capacity reservation: %+v", status.Capacity)
|
|
|
|
|
}
|
|
|
|
|
restarted, err := NewManager(testManagerOptions(filepath.Dir(manager.root)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("restart manager: %v", err)
|
|
|
|
|
}
|
|
|
|
|
recovered, err := restarted.Status(context.Background())
|
|
|
|
|
if err != nil || recovered.State != StateValidated || recovered.Manifest == nil {
|
|
|
|
|
t.Fatalf("recovered status = %+v, %v", recovered, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestManagerRejectsTamperedAndWrongProfilePackages(t *testing.T) {
|
|
|
|
|
for _, test := range []struct {
|
|
|
|
|
name, profile string
|
|
|
|
|
tamper bool
|
|
|
|
|
}{
|
|
|
|
|
{name: "tampered", profile: testProfile, tamper: true},
|
|
|
|
|
{name: "wrong-profile", profile: "other-board"},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
manager, privateKey := newTestManager(t)
|
|
|
|
|
payload := []byte("PAYLOAD-TAMPER-ME")
|
|
|
|
|
packageBytes := signedPackage(t, privateKey, test.profile, payload)
|
|
|
|
|
if test.tamper {
|
|
|
|
|
index := bytes.LastIndex(packageBytes, payload)
|
|
|
|
|
if index < 0 {
|
|
|
|
|
t.Fatal("payload not found in test package")
|
|
|
|
|
}
|
|
|
|
|
packageBytes[index] ^= 0xff
|
|
|
|
|
}
|
|
|
|
|
status, err := manager.Upload(context.Background(), UploadRequest{Filename: "release.bin", ContentLength: int64(len(packageBytes)), Body: bytes.NewReader(packageBytes)})
|
|
|
|
|
if err == nil || Code(err) != "update_validation_failed" || status.State != StateFailed {
|
|
|
|
|
t.Fatalf("status = %+v, err = %v", status, err)
|
|
|
|
|
}
|
|
|
|
|
if status.RecoveryInstructions == "" {
|
|
|
|
|
t.Fatal("failure omitted recovery instructions")
|
|
|
|
|
}
|
|
|
|
|
if status.Capacity.UpdateReserved != 0 {
|
|
|
|
|
t.Fatalf("failed upload retained capacity reservation: %+v", status.Capacity)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestManagerRecoversInterruptedJournal(t *testing.T) {
|
|
|
|
|
manager, _ := newTestManager(t)
|
|
|
|
|
manager.status.State = StateReceiving
|
|
|
|
|
manager.status.OperationID = "fw-interrupted"
|
|
|
|
|
if err := manager.persistLocked(); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(manager.incoming, "fw-interrupted.part"), []byte("partial"), 0o600); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
restarted, err := NewManager(testManagerOptions(filepath.Dir(manager.root)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
status, err := restarted.Status(context.Background())
|
|
|
|
|
if err != nil || status.State != StateFailed || status.FailureCode != "update_interrupted" {
|
|
|
|
|
t.Fatalf("status = %+v, err = %v", status, err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(filepath.Join(manager.incoming, "fw-interrupted.part")); !os.IsNotExist(err) {
|
|
|
|
|
t.Fatalf("partial package still exists: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestManagerRecoveryApplyIsFeatureGatedAndArmsRebootOnce(t *testing.T) {
|
|
|
|
|
manager, privateKey := newTestManager(t)
|
|
|
|
|
packageBytes := signedRecoveryPackage(t, privateKey, []byte("rootfs image"))
|
|
|
|
|
status, err := manager.Upload(context.Background(), UploadRequest{Filename: "release.tera-update", ContentLength: int64(len(packageBytes)), Actor: "admin", Body: bytes.NewReader(packageBytes)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if status.ApplyEnabled || status.ApplyDisabledReason == "" {
|
|
|
|
|
t.Fatalf("recovery apply escaped the default gate: %+v", status)
|
|
|
|
|
}
|
|
|
|
|
if _, err := manager.Apply(context.Background(), ApplyRequest{Actor: "admin", OperationID: status.OperationID}); Code(err) != "update_apply_unsupported" {
|
|
|
|
|
t.Fatalf("disabled Apply error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requested, rebooted := 0, 0
|
|
|
|
|
manager.recoveryApplyEnabled = true
|
|
|
|
|
manager.requestRecovery = func(operationID string) error {
|
|
|
|
|
requested++
|
|
|
|
|
if operationID != status.OperationID {
|
|
|
|
|
t.Fatalf("request operation = %q", operationID)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
manager.reboot = func() error { rebooted++; return nil }
|
|
|
|
|
manager.status.ApplyEnabled, manager.status.ApplyDisabledReason = manager.applyPolicy(*manager.status.Manifest)
|
|
|
|
|
if _, err := manager.Apply(context.Background(), ApplyRequest{Actor: "admin", OperationID: "fw-wrong"}); Code(err) != "update_validation_failed" || requested != 0 || rebooted != 0 {
|
|
|
|
|
t.Fatalf("mismatched operation error=%v requested=%d rebooted=%d", err, requested, rebooted)
|
|
|
|
|
}
|
|
|
|
|
result, err := manager.Apply(context.Background(), ApplyRequest{Actor: "admin", OperationID: status.OperationID})
|
|
|
|
|
if err != nil || result.State != StateRebootPending || requested != 1 || rebooted != 1 {
|
|
|
|
|
t.Fatalf("Apply result=%+v error=%v requested=%d rebooted=%d", result, err, requested, rebooted)
|
|
|
|
|
}
|
|
|
|
|
if _, err := manager.Apply(context.Background(), ApplyRequest{Actor: "admin", OperationID: status.OperationID}); Code(err) != "update_apply_unsupported" || requested != 1 || rebooted != 1 {
|
|
|
|
|
t.Fatalf("repeated Apply error=%v requested=%d rebooted=%d", err, requested, rebooted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestManagerDoesNotRebootWhenRecoveryRequestFails(t *testing.T) {
|
|
|
|
|
manager, privateKey := newTestManager(t)
|
|
|
|
|
manager.recoveryApplyEnabled = true
|
|
|
|
|
manager.requestRecovery = func(string) error { return errors.New("misc readback mismatch") }
|
|
|
|
|
rebooted := 0
|
|
|
|
|
manager.reboot = func() error { rebooted++; return nil }
|
|
|
|
|
packageBytes := signedRecoveryPackage(t, privateKey, []byte("rootfs image"))
|
|
|
|
|
uploaded, err := manager.Upload(context.Background(), UploadRequest{Filename: "release.tera-update", ContentLength: int64(len(packageBytes)), Actor: "admin", Body: bytes.NewReader(packageBytes)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
status, err := manager.Apply(context.Background(), ApplyRequest{Actor: "admin", OperationID: uploaded.OperationID})
|
|
|
|
|
if Code(err) != "update_recovery_request_failed" || status.State != StateFailed || rebooted != 0 {
|
|
|
|
|
t.Fatalf("status=%+v error=%v rebooted=%d", status, err, rebooted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newTestManager(t *testing.T) (*Manager, ed25519.PrivateKey) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
base := t.TempDir()
|
|
|
|
|
options := testManagerOptions(base)
|
|
|
|
|
for _, directory := range []string{options.UpdateRoot, filepath.Join(options.UpdateRoot, "incoming"), filepath.Join(options.UpdateRoot, "validated"), filepath.Join(options.UpdateRoot, "journal"), options.TrustDirectory} {
|
|
|
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
policy := map[string]any{
|
|
|
|
|
"format_version": 1, "layout": "tera-userdata-v1",
|
|
|
|
|
"mount": map[string]any{"source": "/dev/mmcblk0p8", "partlabel": "userdata", "filesystem": "ext4", "required_options": []string{"rw"}},
|
|
|
|
|
"caps_bytes": map[string]int64{"update_staging": 64 << 20, "update_package_max": 32 << 20, "port_events": 1, "audit_logs": 1, "system_events": 1, "state_and_indexes": 1, "export_scratch": 1, "global_free_floor": 1},
|
|
|
|
|
"enforcement": "application-governor-v1", "persistent_uart_payload": false,
|
|
|
|
|
}
|
|
|
|
|
writeJSON(t, options.PolicyPath, policy)
|
|
|
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
anchor := map[string]any{"format_version": 1, "key_id": testKeyID, "algorithm": "ed25519", "public_key": base64.StdEncoding.EncodeToString(publicKey), "allowed_products": []string{testProduct}, "allowed_profiles": []string{testProfile}}
|
|
|
|
|
writeJSON(t, filepath.Join(options.TrustDirectory, testKeyID+".json"), anchor)
|
|
|
|
|
manager, err := NewManager(options)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return manager, privateKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testManagerOptions(base string) ManagerOptions {
|
|
|
|
|
return ManagerOptions{
|
|
|
|
|
UpdateRoot: filepath.Join(base, "update"), PolicyPath: filepath.Join(base, "storage-layout.json"), TrustDirectory: filepath.Join(base, "trust"),
|
|
|
|
|
Product: testProduct, Profile: testProfile, CurrentVersion: "1.0.0", Clock: func() time.Time { return time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC) },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func signedPackage(t *testing.T, privateKey ed25519.PrivateKey, profile string, payload []byte) []byte {
|
|
|
|
|
t.Helper()
|
|
|
|
|
digest := sha256.Sum256(payload)
|
|
|
|
|
manifest := Manifest{FormatVersion: 1, PackageID: "test-package-1", Product: testProduct, HardwareProfile: profile, Version: "1.1.0", MinimumCurrentVersion: "1.0.0", CreatedAt: time.Date(2026, 7, 23, 8, 0, 0, 0, time.UTC), SigningKeyID: testKeyID}
|
|
|
|
|
manifest.Payloads = []Payload{{Name: "application", Path: "payload/application.bin", Class: "application", Size: int64(len(payload)), SHA256: hex.EncodeToString(digest[:])}}
|
|
|
|
|
manifest.Apply.Capability = "validation-only"
|
|
|
|
|
manifest.Recovery.Capability = "external-service"
|
|
|
|
|
manifest.Recovery.Instructions = "Upload a known-good signed package; running firmware remains unchanged."
|
|
|
|
|
manifestBytes, err := json.Marshal(manifest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
envelope, err := json.Marshal(signatureEnvelope{Algorithm: "ed25519", KeyID: testKeyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
writer := tar.NewWriter(&output)
|
|
|
|
|
for _, entry := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
data []byte
|
|
|
|
|
}{{manifestPath, manifestBytes}, {signaturePath, envelope}, {"payload/application.bin", payload}} {
|
|
|
|
|
if err := writer.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o600, Size: int64(len(entry.data)), ModTime: time.Unix(0, 0)}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := writer.Write(entry.data); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return output.Bytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func signedRecoveryPackage(t *testing.T, privateKey ed25519.PrivateKey, payload []byte) []byte {
|
|
|
|
|
t.Helper()
|
|
|
|
|
digest := sha256.Sum256(payload)
|
|
|
|
|
manifest := Manifest{FormatVersion: 1, PackageID: "test-recovery-package-1", Product: testProduct, HardwareProfile: testProfile, Version: "1.1.0", MinimumCurrentVersion: "1.0.0", CreatedAt: time.Date(2026, 7, 23, 8, 0, 0, 0, time.UTC), SigningKeyID: testKeyID}
|
|
|
|
|
manifest.Payloads = []Payload{{Name: "rootfs", Path: "payload/rootfs.img", Class: "full-rootfs", Size: int64(len(payload)), SHA256: hex.EncodeToString(digest[:]), TargetPartition: "rootfs", Encoding: "raw", ExpandedSize: int64(len(payload)), ExpandedSHA256: hex.EncodeToString(digest[:])}}
|
|
|
|
|
manifest.Apply.Capability = ApplyCapabilityRecoveryV1
|
|
|
|
|
manifest.Apply.RebootRequired = true
|
|
|
|
|
manifest.Recovery.Capability = RecoveryCapabilityOfflineV1
|
|
|
|
|
manifest.Recovery.Instructions = "Leave the request set and use COM5 recovery on failure."
|
|
|
|
|
manifestBytes, err := json.Marshal(manifest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
envelope, err := json.Marshal(signatureEnvelope{Algorithm: "ed25519", KeyID: testKeyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
writer := tar.NewWriter(&output)
|
|
|
|
|
for _, entry := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
data []byte
|
|
|
|
|
}{{manifestPath, manifestBytes}, {signaturePath, envelope}, {"payload/rootfs.img", payload}} {
|
|
|
|
|
if err := writer.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o600, Size: int64(len(entry.data)), ModTime: time.Unix(0, 0)}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := writer.Write(entry.data); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return output.Bytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeJSON(t *testing.T, path string, value any) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
data, err := json.Marshal(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFactoryResetErasesUploadedFirmwareAndKeepsInstalledVersion(t *testing.T) {
|
|
|
|
|
m, key := newTestManager(t)
|
|
|
|
|
payload := signedPackage(t, key, testProfile, []byte("customer-upload"))
|
|
|
|
|
if _, err := m.Upload(context.Background(), UploadRequest{Filename: "release.bin", ContentLength: int64(len(payload)), Body: bytes.NewReader(payload)}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := m.FactoryReset(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, dir := range []string{m.incoming, m.validated} {
|
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
|
|
|
if err != nil || len(entries) != 0 {
|
|
|
|
|
t.Fatal("uploaded firmware survived reset")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
reopened, err := NewManager(testManagerOptions(filepath.Dir(m.root)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
status, err := reopened.Status(context.Background())
|
|
|
|
|
if err != nil || status.State != StateIdle || status.DeviceVersion != m.currentVersion || status.Manifest != nil {
|
|
|
|
|
t.Fatal("firmware reset state is incorrect")
|
|
|
|
|
}
|
|
|
|
|
m.active = true
|
|
|
|
|
if err := m.FactoryReset(context.Background()); err == nil {
|
|
|
|
|
t.Fatal("reset accepted during update")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestManagerRecoversFieldApplyPolicyJournal(t *testing.T) {
|
|
|
|
|
manager, _ := newTestManager(t)
|
|
|
|
|
data, err := os.ReadFile(manager.journalPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var value map[string]any
|
|
|
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
value["apply_policy"] = "field"
|
|
|
|
|
data, err = json.Marshal(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(manager.journalPath, data, 0600); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
reopened, err := NewManager(testManagerOptions(filepath.Dir(manager.root)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
status, err := reopened.Status(context.Background())
|
|
|
|
|
if err != nil || status.ApplyPolicy != "field" {
|
|
|
|
|
t.Fatal("field policy was not preserved")
|
|
|
|
|
}
|
|
|
|
|
}
|