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