// Package recoveryapply implements the offline, resumable non-A/B partition // writer. It is used only by the authenticated recovery initramfs. package recoveryapply import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "tera-clone/services/tera-api/internal/storage/atomicfile" "tera-clone/services/tera-api/internal/update" ) const JournalFormatVersion = 1 // The recovery updater runs as root, while tera-updated reads status.json as // its unprivileged service account after the normal system boots. The parent // journal directory remains 0750 and service-owned, so 0644 makes the durable // hand-off readable without exposing it outside that protected directory. const recoveryStatusMode = 0o644 type Target struct { Path string MaxBytes int64 } type Transition struct { Phase string PayloadIndex int PayloadName string TargetPartition string } type Options struct { OperationID string PackagePath string JournalPath string StatusPath string TrustDirectory string Product string Profile string CurrentVersion string Targets map[string]Target AllowRegularTarget bool ClearRecovery func() error Clock func() time.Time AfterTransition func(Transition) error } type Journal struct { FormatVersion int `json:"format_version"` OperationID string `json:"operation_id"` PackageID string `json:"package_id"` Phase string `json:"phase"` PayloadIndex int `json:"payload_index"` PayloadName string `json:"payload_name,omitempty"` TargetPartition string `json:"target_partition,omitempty"` Attempt int `json:"attempt"` LastError string `json:"last_error,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type Engine struct{ options Options } func New(options Options) (*Engine, error) { if options.Clock == nil { options.Clock = time.Now } if options.OperationID == "" || options.PackagePath == "" || options.JournalPath == "" || options.StatusPath == "" || options.TrustDirectory == "" || options.Product == "" || options.Profile == "" || options.CurrentVersion == "" || len(options.Targets) == 0 || options.ClearRecovery == nil { return nil, errors.New("complete recovery apply options are required") } return &Engine{options: options}, nil } func (e *Engine) Run() (runErr error) { defer func() { if runErr != nil { e.recordFailure(runErr) } }() if err := requireRegularFile(e.options.PackagePath); err != nil { return err } manifest, _, err := update.VerifyPackage(e.options.PackagePath, e.options.TrustDirectory, e.options.Product, e.options.Profile, e.options.CurrentVersion) if err != nil { return fmt.Errorf("revalidate recovery package: %w", err) } if manifest.Apply.Capability != update.ApplyCapabilityRecoveryV1 || manifest.Recovery.Capability != update.RecoveryCapabilityOfflineV1 { return errors.New("validated package is not an offline recovery-v1 package") } payloads := orderedPayloads(manifest.Payloads) journal, found, err := e.loadJournal() if err != nil { return err } if !found { journal = Journal{FormatVersion: JournalFormatVersion, OperationID: e.options.OperationID, PackageID: manifest.PackageID, Phase: "ready", PayloadIndex: 0, UpdatedAt: e.options.Clock().UTC()} if err := e.persistJournal(journal); err != nil { return err } } else if journal.FormatVersion != JournalFormatVersion || journal.OperationID != e.options.OperationID || journal.PackageID != manifest.PackageID || journal.PayloadIndex < 0 || journal.PayloadIndex > len(payloads) { return errors.New("recovery journal identity or bounds do not match the request") } if journal.Phase == "succeeded" { return e.options.ClearRecovery() } start := journal.PayloadIndex if journal.Phase == "verified" { start++ } for index := start; index < len(payloads); index++ { payload := payloads[index] target, found := e.options.Targets[payload.TargetPartition] if !found { return fmt.Errorf("target partition %q is not configured", payload.TargetPartition) } if target.MaxBytes > 0 && payload.ExpandedSize > target.MaxBytes { return fmt.Errorf("payload %q exceeds target partition %q", payload.Name, payload.TargetPartition) } journal.PayloadIndex = index journal.PayloadName = payload.Name journal.TargetPartition = payload.TargetPartition journal.Attempt++ journal.LastError = "" journal.Phase = "writing" journal.UpdatedAt = e.options.Clock().UTC() if err := e.persistJournal(journal); err != nil { return err } e.persistStatus(update.StateApplying, journal, manifest, progress(index, len(payloads), 10), "Writing verified payload to "+payload.TargetPartition) if err := e.after(journal); err != nil { return err } if err := e.writePayload(payload, target); err != nil { return err } journal.Phase = "verifying" journal.UpdatedAt = e.options.Clock().UTC() if err := e.persistJournal(journal); err != nil { return err } e.persistStatus(update.StateVerifying, journal, manifest, progress(index, len(payloads), 70), "Reading back "+payload.TargetPartition) if err := e.after(journal); err != nil { return err } if err := e.verifyReadback(payload, target); err != nil { return err } journal.Phase = "verified" journal.UpdatedAt = e.options.Clock().UTC() if err := e.persistJournal(journal); err != nil { return err } e.persistStatus(update.StateApplying, journal, manifest, progress(index, len(payloads), 95), payload.TargetPartition+" readback verified") if err := e.after(journal); err != nil { return err } } journal.Phase = "succeeded" journal.PayloadIndex = len(payloads) journal.PayloadName = "" journal.TargetPartition = "" journal.LastError = "" journal.UpdatedAt = e.options.Clock().UTC() if err := e.persistJournal(journal); err != nil { return err } e.persistStatus(update.StateSucceeded, journal, manifest, 100, "Recovery payloads verified; restoring normal boot") if err := e.after(journal); err != nil { return err } if err := e.options.ClearRecovery(); err != nil { return fmt.Errorf("clear recovery request: %w", err) } return nil } func orderedPayloads(payloads []update.Payload) []update.Payload { ordered := append([]update.Payload(nil), payloads...) priority := map[string]int{"rootfs": 0, "boot": 1} sort.SliceStable(ordered, func(i, j int) bool { return priority[ordered[i].TargetPartition] < priority[ordered[j].TargetPartition] }) return ordered } func progress(index, count, within int) int { if count <= 0 { return 0 } return (index*100 + within) / count } func (e *Engine) writePayload(payload update.Payload, target Target) error { file, err := e.openTarget(target.Path, os.O_WRONLY) if err != nil { return err } if _, err := file.Seek(0, io.SeekStart); err != nil { file.Close() return fmt.Errorf("seek target %s: %w", payload.TargetPartition, err) } streamErr := update.StreamPayload(e.options.PackagePath, payload, file) syncErr := file.Sync() closeErr := file.Close() if streamErr != nil { return streamErr } if syncErr != nil { return fmt.Errorf("sync target %s: %w", payload.TargetPartition, syncErr) } if closeErr != nil { return fmt.Errorf("close target %s: %w", payload.TargetPartition, closeErr) } return nil } func (e *Engine) verifyReadback(payload update.Payload, target Target) error { file, err := e.openTarget(target.Path, os.O_RDONLY) if err != nil { return err } defer file.Close() hash := sha256.New() written, err := io.CopyN(hash, file, payload.ExpandedSize) if err != nil || written != payload.ExpandedSize { return fmt.Errorf("read back target %s: read %d of %d bytes: %w", payload.TargetPartition, written, payload.ExpandedSize, err) } if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), payload.ExpandedSHA256) { return fmt.Errorf("target %s readback SHA-256 mismatch", payload.TargetPartition) } return nil } func (e *Engine) openTarget(path string, flag int) (*os.File, error) { info, err := os.Lstat(path) if err != nil { return nil, fmt.Errorf("inspect recovery target: %w", err) } block := info.Mode()&os.ModeDevice != 0 && info.Mode()&os.ModeCharDevice == 0 if info.Mode()&os.ModeSymlink != 0 || (!block && !(e.options.AllowRegularTarget && info.Mode().IsRegular())) { return nil, fmt.Errorf("recovery target %s is not an approved non-symlink block device", path) } file, err := os.OpenFile(path, flag, 0) if err != nil { return nil, fmt.Errorf("open recovery target %s: %w", path, err) } return file, nil } func (e *Engine) loadJournal() (Journal, bool, error) { data, err := os.ReadFile(e.options.JournalPath) if os.IsNotExist(err) { return Journal{}, false, nil } if err != nil { return Journal{}, false, fmt.Errorf("read recovery journal: %w", err) } var journal Journal if err := decodeSingleJSON(data, &journal); err != nil { return Journal{}, false, fmt.Errorf("decode recovery journal: %w", err) } return journal, true, nil } func (e *Engine) persistJournal(journal Journal) error { data, err := json.MarshalIndent(journal, "", " ") if err != nil { return err } if err := atomicfile.Write(e.options.JournalPath, append(data, '\n'), 0o600); err != nil { return fmt.Errorf("persist recovery journal: %w", err) } return nil } func (e *Engine) persistStatus(state string, journal Journal, manifest update.Manifest, percent int, message string) { status := update.Status{FormatVersion: 1, OperationID: e.options.OperationID, Manifest: &manifest} if data, err := os.ReadFile(e.options.StatusPath); err == nil { _ = json.Unmarshal(data, &status) status.Manifest = &manifest } status.State = state status.Message = message status.ProgressPercent = percent status.ApplyEnabled = false status.ApplyDisabledReason = "Recovery apply is in progress" status.RecoveryStep = journal.Phase status.RecoveryAttempt = journal.Attempt status.FailureCode = "" status.FailureReason = "" status.UpdatedAt = e.options.Clock().UTC() if data, err := json.MarshalIndent(status, "", " "); err == nil { _ = atomicfile.Write(e.options.StatusPath, append(data, '\n'), recoveryStatusMode) } } func (e *Engine) recordFailure(runErr error) { journal, found, journalErr := e.loadJournal() persistJournal := journalErr == nil if journalErr != nil { journal = Journal{FormatVersion: JournalFormatVersion, OperationID: e.options.OperationID, Phase: "journal-invalid"} } if journalErr == nil && !found { journal = Journal{FormatVersion: JournalFormatVersion, OperationID: e.options.OperationID, Phase: "failed"} } journal.LastError = boundedError(runErr) journal.UpdatedAt = e.options.Clock().UTC() if persistJournal { _ = e.persistJournal(journal) } status := update.Status{FormatVersion: 1, OperationID: e.options.OperationID} if data, err := os.ReadFile(e.options.StatusPath); err == nil { _ = json.Unmarshal(data, &status) } status.State = update.StateFailed status.Message = "Recovery updater stopped safely; the recovery request remains set" status.ApplyEnabled = false status.ApplyDisabledReason = "Recovery service action is required" status.RecoveryStep = journal.Phase status.RecoveryAttempt = journal.Attempt status.FailureCode = "recovery_apply_failed" status.FailureReason = journal.LastError status.RecoveryInstructions = "Keep both power inputs connected, preserve userdata and misc, and follow the approved product service recovery procedure. The final product does not expose the development COM5 console. A subsequent recovery boot may safely retry the recorded payload step." status.UpdatedAt = e.options.Clock().UTC() if data, err := json.MarshalIndent(status, "", " "); err == nil { _ = atomicfile.Write(e.options.StatusPath, append(data, '\n'), recoveryStatusMode) } } func (e *Engine) after(journal Journal) error { if e.options.AfterTransition == nil { return nil } return e.options.AfterTransition(Transition{Phase: journal.Phase, PayloadIndex: journal.PayloadIndex, PayloadName: journal.PayloadName, TargetPartition: journal.TargetPartition}) } func requireRegularFile(path string) error { info, err := os.Lstat(path) if err != nil { return fmt.Errorf("inspect validated package: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return errors.New("validated package is not a regular non-symlink file") } return nil } func decodeSingleJSON(data []byte, target any) error { decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return errors.New("must contain exactly one JSON value") } return nil } func boundedError(err error) string { value := strings.TrimSpace(err.Error()) if len(value) > 512 { return value[:512] } return value } func JournalPath(updateRoot, operationID string) string { return filepath.Join(updateRoot, "journal", "recovery-"+operationID+".json") }