You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

278 lines
12 KiB

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)
}
}