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.

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