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.
325 lines
12 KiB
325 lines
12 KiB
package recoveryapply
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"tera-clone/services/tera-api/internal/update"
|
|
)
|
|
|
|
func TestEngineWritesRootfsThenBootAndClearsRequest(t *testing.T) {
|
|
fixture := newFixture(t)
|
|
var transitions []Transition
|
|
cleared := 0
|
|
options := fixture.options()
|
|
options.ClearRecovery = func() error { cleared++; return nil }
|
|
options.AfterTransition = func(value Transition) error { transitions = append(transitions, value); return nil }
|
|
engine, err := New(options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := engine.Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cleared != 1 {
|
|
t.Fatalf("clear count = %d", cleared)
|
|
}
|
|
assertFilePrefix(t, fixture.rootfsTarget, fixture.rootfs)
|
|
assertFilePrefix(t, fixture.bootTarget, fixture.boot)
|
|
if len(transitions) != 7 || transitions[0].TargetPartition != "rootfs" || transitions[3].TargetPartition != "boot" || transitions[6].Phase != "succeeded" {
|
|
t.Fatalf("unexpected transitions: %+v", transitions)
|
|
}
|
|
var journal Journal
|
|
readJSON(t, options.JournalPath, &journal)
|
|
if journal.Phase != "succeeded" || journal.PayloadIndex != 2 {
|
|
t.Fatalf("journal = %+v", journal)
|
|
}
|
|
var status update.Status
|
|
readJSON(t, options.StatusPath, &status)
|
|
if status.State != update.StateSucceeded || status.ProgressPercent != 100 || status.ApplyEnabled {
|
|
t.Fatalf("status = %+v", status)
|
|
}
|
|
assertStatusReadableAfterRecovery(t, options.StatusPath)
|
|
}
|
|
|
|
func TestEngineResumesTheRecordedPayloadAfterInterruption(t *testing.T) {
|
|
fixture := newFixture(t)
|
|
options := fixture.options()
|
|
cleared := 0
|
|
options.ClearRecovery = func() error { cleared++; return nil }
|
|
injected := false
|
|
options.AfterTransition = func(value Transition) error {
|
|
if !injected && value.TargetPartition == "rootfs" && value.Phase == "verifying" {
|
|
injected = true
|
|
return errors.New("injected updater termination")
|
|
}
|
|
return nil
|
|
}
|
|
engine, _ := New(options)
|
|
if err := engine.Run(); err == nil || cleared != 0 {
|
|
t.Fatalf("first run error=%v clear=%d", err, cleared)
|
|
}
|
|
|
|
options.AfterTransition = nil
|
|
engine, _ = New(options)
|
|
if err := engine.Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cleared != 1 {
|
|
t.Fatalf("clear count after resume = %d", cleared)
|
|
}
|
|
assertFilePrefix(t, fixture.rootfsTarget, fixture.rootfs)
|
|
assertFilePrefix(t, fixture.bootTarget, fixture.boot)
|
|
var journal Journal
|
|
readJSON(t, options.JournalPath, &journal)
|
|
if journal.Attempt < 3 || journal.Phase != "succeeded" {
|
|
t.Fatalf("resume journal = %+v", journal)
|
|
}
|
|
}
|
|
|
|
func TestEngineResumesAfterEveryDurableTransition(t *testing.T) {
|
|
for stopAfter := 1; stopAfter <= 7; stopAfter++ {
|
|
t.Run(fmt.Sprintf("transition-%d", stopAfter), func(t *testing.T) {
|
|
fixture := newFixture(t)
|
|
options := fixture.options()
|
|
cleared, transitions := 0, 0
|
|
options.ClearRecovery = func() error { cleared++; return nil }
|
|
options.AfterTransition = func(Transition) error {
|
|
transitions++
|
|
if transitions == stopAfter {
|
|
return errors.New("injected reset or watchdog interruption")
|
|
}
|
|
return nil
|
|
}
|
|
engine, _ := New(options)
|
|
if err := engine.Run(); err == nil || cleared != 0 {
|
|
t.Fatalf("first run error=%v clear=%d", err, cleared)
|
|
}
|
|
options.AfterTransition = nil
|
|
engine, _ = New(options)
|
|
if err := engine.Run(); err != nil || cleared != 1 {
|
|
t.Fatalf("resume error=%v clear=%d", err, cleared)
|
|
}
|
|
assertFilePrefix(t, fixture.rootfsTarget, fixture.rootfs)
|
|
assertFilePrefix(t, fixture.bootTarget, fixture.boot)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngineKeepsRecoveryArmedOnReadbackMismatch(t *testing.T) {
|
|
fixture := newFixture(t)
|
|
options := fixture.options()
|
|
cleared := 0
|
|
options.ClearRecovery = func() error { cleared++; return nil }
|
|
options.AfterTransition = func(value Transition) error {
|
|
if value.TargetPartition == "rootfs" && value.Phase == "verifying" {
|
|
file, err := os.OpenFile(fixture.rootfsTarget, os.O_WRONLY, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteAt([]byte{0xff}, 0)
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
engine, _ := New(options)
|
|
err := engine.Run()
|
|
if err == nil || !bytes.Contains([]byte(err.Error()), []byte("readback SHA-256 mismatch")) || cleared != 0 {
|
|
t.Fatalf("error=%v clear=%d", err, cleared)
|
|
}
|
|
var status update.Status
|
|
readJSON(t, options.StatusPath, &status)
|
|
if status.State != update.StateFailed || status.FailureCode != "recovery_apply_failed" || status.RecoveryInstructions == "" {
|
|
t.Fatalf("failure status = %+v", status)
|
|
}
|
|
if strings.Contains(status.RecoveryInstructions, "follow the COM5") || !strings.Contains(status.RecoveryInstructions, "does not expose the development COM5 console") {
|
|
t.Fatalf("failure instructions depend on the development console: %q", status.RecoveryInstructions)
|
|
}
|
|
assertStatusReadableAfterRecovery(t, options.StatusPath)
|
|
}
|
|
|
|
func assertStatusReadableAfterRecovery(t *testing.T, path string) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
// Windows reports synthesized permissions for files created by the
|
|
// native host test binary. Linux mode enforcement is checked below.
|
|
return
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != recoveryStatusMode {
|
|
t.Fatalf("status mode = %04o, want %04o", info.Mode().Perm(), recoveryStatusMode)
|
|
}
|
|
}
|
|
|
|
func TestEnginePreservesMalformedJournalForServiceDiagnosis(t *testing.T) {
|
|
fixture := newFixture(t)
|
|
malformed := []byte(`{"format_version":1,"operation_id":`)
|
|
if err := os.WriteFile(fixture.journalPath, malformed, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
options := fixture.options()
|
|
options.ClearRecovery = func() error { t.Fatal("malformed journal cleared recovery"); return nil }
|
|
engine, _ := New(options)
|
|
if err := engine.Run(); err == nil {
|
|
t.Fatal("malformed journal was accepted")
|
|
}
|
|
after, err := os.ReadFile(fixture.journalPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(after, malformed) {
|
|
t.Fatal("malformed journal evidence was overwritten")
|
|
}
|
|
}
|
|
|
|
type fixture struct {
|
|
base, packagePath, trust, statusPath, journalPath string
|
|
rootfsTarget, bootTarget string
|
|
rootfs, boot []byte
|
|
}
|
|
|
|
func newFixture(t *testing.T) fixture {
|
|
t.Helper()
|
|
base := t.TempDir()
|
|
trust := filepath.Join(base, "trust")
|
|
journalDir := filepath.Join(base, "journal")
|
|
if err := os.MkdirAll(trust, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(journalDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeJSON(t, filepath.Join(trust, "test-key.json"), map[string]any{
|
|
"format_version": 1, "key_id": "test-key", "algorithm": "ed25519", "public_key": base64.StdEncoding.EncodeToString(publicKey),
|
|
"allowed_products": []string{"tera-core-x48-pro"}, "allowed_profiles": []string{"fet3568j-c"},
|
|
})
|
|
rootfs := bytes.Repeat([]byte("rootfs-block-"), 64)
|
|
boot := bytes.Repeat([]byte("signed-boot-fit-"), 16)
|
|
packagePath := filepath.Join(base, "recovery.tera-update")
|
|
writeRecoveryPackage(t, packagePath, privateKey, rootfs, boot)
|
|
rootfsTarget := filepath.Join(base, "rootfs.device")
|
|
bootTarget := filepath.Join(base, "boot.device")
|
|
if err := os.WriteFile(rootfsTarget, make([]byte, len(rootfs)+64), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(bootTarget, make([]byte, len(boot)+64), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
statusPath := filepath.Join(journalDir, "status.json")
|
|
writeJSON(t, statusPath, update.Status{FormatVersion: 1, OperationID: "fw-test-operation", State: update.StateRebootPending})
|
|
return fixture{base: base, packagePath: packagePath, trust: trust, statusPath: statusPath, journalPath: filepath.Join(journalDir, "recovery-fw-test-operation.json"), rootfsTarget: rootfsTarget, bootTarget: bootTarget, rootfs: rootfs, boot: boot}
|
|
}
|
|
|
|
func (f fixture) options() Options {
|
|
return Options{
|
|
OperationID: "fw-test-operation", PackagePath: f.packagePath, JournalPath: f.journalPath, StatusPath: f.statusPath,
|
|
TrustDirectory: f.trust, Product: "tera-core-x48-pro", Profile: "fet3568j-c", CurrentVersion: "1.0.0",
|
|
Targets: map[string]Target{"rootfs": {Path: f.rootfsTarget, MaxBytes: int64(len(f.rootfs) + 64)}, "boot": {Path: f.bootTarget, MaxBytes: int64(len(f.boot) + 64)}},
|
|
AllowRegularTarget: true, Clock: func() time.Time { return time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) },
|
|
}
|
|
}
|
|
|
|
func writeRecoveryPackage(t *testing.T, packagePath string, privateKey ed25519.PrivateKey, rootfs, boot []byte) {
|
|
t.Helper()
|
|
var compressed bytes.Buffer
|
|
gzipWriter := gzip.NewWriter(&compressed)
|
|
if _, err := gzipWriter.Write(rootfs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gzipWriter.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rootfsArchive := compressed.Bytes()
|
|
rootfsArchiveHash := sha256.Sum256(rootfsArchive)
|
|
rootfsHash := sha256.Sum256(rootfs)
|
|
bootHash := sha256.Sum256(boot)
|
|
manifest := update.Manifest{FormatVersion: 1, PackageID: "package-1", Product: "tera-core-x48-pro", HardwareProfile: "fet3568j-c", Version: "1.1.0", MinimumCurrentVersion: "1.0.0", CreatedAt: time.Date(2026, 7, 24, 11, 0, 0, 0, time.UTC), SigningKeyID: "test-key"}
|
|
manifest.Payloads = []update.Payload{
|
|
{Name: "rootfs", Path: "payload/rootfs.img.gz", Class: "full-rootfs", Size: int64(len(rootfsArchive)), SHA256: hex.EncodeToString(rootfsArchiveHash[:]), TargetPartition: "rootfs", Encoding: "gzip", ExpandedSize: int64(len(rootfs)), ExpandedSHA256: hex.EncodeToString(rootfsHash[:])},
|
|
{Name: "boot", Path: "payload/boot.img", Class: "boot", Size: int64(len(boot)), SHA256: hex.EncodeToString(bootHash[:]), TargetPartition: "boot", Encoding: "raw", ExpandedSize: int64(len(boot)), ExpandedSHA256: hex.EncodeToString(bootHash[:])},
|
|
}
|
|
manifest.Apply.Capability = update.ApplyCapabilityRecoveryV1
|
|
manifest.Apply.RebootRequired = true
|
|
manifest.Recovery.Capability = update.RecoveryCapabilityOfflineV1
|
|
manifest.Recovery.Instructions = "Keep the device in recovery on failure."
|
|
manifestBytes, _ := json.Marshal(manifest)
|
|
signature, _ := json.Marshal(map[string]string{"algorithm": "ed25519", "key_id": "test-key", "signature": base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))})
|
|
file, err := os.OpenFile(packagePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer := tar.NewWriter(file)
|
|
for _, entry := range []struct {
|
|
name string
|
|
data []byte
|
|
}{{"manifest.json", manifestBytes}, {"manifest.sig", signature}, {"payload/rootfs.img.gz", rootfsArchive}, {"payload/boot.img", boot}} {
|
|
if err := writer.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o600, Size: int64(len(entry.data))}); 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)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func assertFilePrefix(t *testing.T, path string, expected []byte) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(data[:len(expected)], expected) {
|
|
t.Fatalf("%s prefix mismatch", path)
|
|
}
|
|
}
|
|
|
|
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 readJSON(t *testing.T, path string, target any) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(data, target); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|