package update import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "strings" "sync" "time" "tera-clone/services/tera-api/internal/storage/atomicfile" "tera-clone/services/tera-api/internal/storage/capacity" ) var safeFilename = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) type ManagerOptions struct { UpdateRoot string PolicyPath string TrustDirectory string Product string Profile string CurrentVersion string Clock func() time.Time RecoveryApplyEnabled bool RequestRecovery func(string) error Reboot func() error } type Manager struct { mu sync.Mutex active bool status Status root string incoming string validated string journalPath string trustDirectory string product string profile string currentVersion string clock func() time.Time governor *capacity.Governor recoveryApplyEnabled bool requestRecovery func(string) error reboot func() error } func NewManager(options ManagerOptions) (*Manager, error) { if options.Clock == nil { options.Clock = time.Now } if options.Product == "" || options.Profile == "" || options.CurrentVersion == "" || options.TrustDirectory == "" { return nil, errors.New("update product, profile, current version, and trust directory are required") } if options.RecoveryApplyEnabled && (options.RequestRecovery == nil || options.Reboot == nil) { return nil, errors.New("enabled recovery apply requires recovery-request and reboot adapters") } root := filepath.Clean(options.UpdateRoot) directories := []string{root, filepath.Join(root, "incoming"), filepath.Join(root, "validated"), filepath.Join(root, "journal")} for _, directory := range directories { if err := requireDirectory(directory); err != nil { return nil, err } } if err := requireDirectory(options.TrustDirectory); err != nil { return nil, err } policy, err := capacity.LoadPolicy(options.PolicyPath) if err != nil { return nil, err } governor, err := capacity.New(root, policy) if err != nil { return nil, err } manager := &Manager{ root: root, incoming: directories[1], validated: directories[2], journalPath: filepath.Join(directories[3], "status.json"), trustDirectory: options.TrustDirectory, product: options.Product, profile: options.Profile, currentVersion: options.CurrentVersion, clock: options.Clock, governor: governor, recoveryApplyEnabled: options.RecoveryApplyEnabled, requestRecovery: options.RequestRecovery, reboot: options.Reboot, } if err := manager.recover(); err != nil { return nil, err } return manager, nil } func (m *Manager) Status(context.Context) (Status, error) { m.mu.Lock() defer m.mu.Unlock() status := cloneStatus(m.status) snapshot, err := m.governor.Snapshot() if err != nil { return Status{}, err } status.Capacity = snapshot return status, nil } func (m *Manager) Upload(ctx context.Context, request UploadRequest) (Status, error) { filename := strings.TrimSpace(request.Filename) if request.Body == nil || request.ContentLength <= 0 || !safeFilename.MatchString(filename) || filepath.Base(filename) != filename { return Status{}, &Error{Code: "update_validation_failed", Message: "a bounded local package filename and content length are required"} } m.mu.Lock() if m.active { m.mu.Unlock() return Status{}, &Error{Code: "update_conflict", Message: "another firmware upload is already active"} } m.active = true m.mu.Unlock() defer func() { m.mu.Lock() m.active = false m.mu.Unlock() }() reservation, snapshot, err := m.governor.Reserve(request.ContentLength) if err != nil { code := "update_capacity_failed" if !errors.Is(err, capacity.ErrInsufficientSpace) { code = "update_unavailable" } return Status{}, &Error{Code: code, Message: err.Error()} } defer reservation.Release() operationID, err := randomID() if err != nil { return Status{}, &Error{Code: "update_unavailable", Message: "cannot allocate update operation id"} } failUpload := func(code, reason string, cleanupPaths ...string) (Status, error) { reservation.Release() return m.fail(operationID, code, reason, cleanupPaths...) } now := m.clock().UTC() status := Status{ FormatVersion: 1, OperationID: operationID, State: StateReceiving, Message: "Receiving local firmware package", Filename: filename, DeviceVersion: m.currentVersion, BytesTotal: request.ContentLength, ApplyEnabled: false, ApplyDisabledReason: "Package validation has not completed", RecoveryInstructions: "If validation fails, correct the package and upload again. No running firmware is changed.", Capacity: snapshot, CreatedAt: now, UpdatedAt: now, } if err := m.setStatus(status, true); err != nil { return Status{}, &Error{Code: "update_unavailable", Message: err.Error()} } partPath := filepath.Join(m.incoming, operationID+".part") file, err := os.OpenFile(partPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return failUpload("update_staging_failed", "Cannot create the protected staging file", partPath) } buffer := make([]byte, 1024*1024) reader := &contextReader{ctx: ctx, reader: io.LimitReader(request.Body, request.ContentLength+1)} progress := &progressWriter{writer: file, onProgress: func(received int64) { m.updateProgress(received) }} written, copyErr := io.CopyBuffer(progress, reader, buffer) syncErr := file.Sync() closeErr := file.Close() if copyErr != nil || syncErr != nil || closeErr != nil || written != request.ContentLength { return failUpload("update_receive_failed", "Firmware upload was interrupted or its length did not match", partPath) } stagedPath := filepath.Join(m.incoming, operationID+".pkg") if err := os.Rename(partPath, stagedPath); err != nil || syncDirectory(m.incoming) != nil { return failUpload("update_staging_failed", "Cannot durably stage the firmware package", partPath, stagedPath) } status = m.transition(StateStaged, "Firmware package staged", 80) if err := m.setStatus(status, true); err != nil { return failUpload("update_journal_failed", "Cannot persist the staged state", stagedPath) } status = m.transition(StateValidating, "Validating signature, compatibility, version, size, and payload digests", 85) if err := m.setStatus(status, true); err != nil { return failUpload("update_journal_failed", "Cannot persist the validation state", stagedPath) } manifest, signerKeyID, err := VerifyPackage(stagedPath, m.trustDirectory, m.product, m.profile, m.currentVersion) if err != nil { return failUpload("update_validation_failed", err.Error(), stagedPath) } validatedPath := filepath.Join(m.validated, operationID+".pkg") if err := os.Rename(stagedPath, validatedPath); err != nil || syncDirectory(m.incoming) != nil || syncDirectory(m.validated) != nil { return failUpload("update_staging_failed", "Cannot commit the validated package", stagedPath, validatedPath) } status = m.transition(StateValidated, "Package is authentic and compatible; running firmware was not changed", 100) status.Manifest = &manifest status.SignerKeyID = signerKeyID status.PackagePath = validatedPath status.ApplyEnabled, status.ApplyDisabledReason = m.applyPolicy(manifest) status.RecoveryInstructions = manifest.Recovery.Instructions reservation.Release() if snapshot, snapshotErr := m.governor.Snapshot(); snapshotErr == nil { status.Capacity = snapshot } if err := m.setStatus(status, true); err != nil { return failUpload("update_journal_failed", "Validated package exists but its journal could not be committed", validatedPath) } // Keep the previously validated package until the new validated state is // durable. Cleanup after the journal commit preserves an atomic fallback if // the final status write fails. m.removeOlderValidated(validatedPath) if snapshot, snapshotErr := m.governor.Snapshot(); snapshotErr == nil { status.Capacity = snapshot _ = m.setStatus(status, false) } return cloneStatus(status), nil } func (m *Manager) Apply(ctx context.Context, request ApplyRequest) (Status, error) { if strings.TrimSpace(request.Actor) == "" { return Status{}, &Error{Code: "update_validation_failed", Message: "an authenticated update actor is required"} } m.mu.Lock() if m.active { m.mu.Unlock() return Status{}, &Error{Code: "update_conflict", Message: "another firmware operation is already active"} } status := cloneStatus(m.status) m.active = true m.mu.Unlock() defer func() { m.mu.Lock() m.active = false m.mu.Unlock() }() if status.State != StateValidated || status.Manifest == nil { return status, &Error{Code: "update_apply_unsupported", Message: "a signed recovery package must be validated before apply"} } if request.OperationID == "" || request.OperationID != status.OperationID { return status, &Error{Code: "update_validation_failed", Message: "apply operation id does not match the validated package"} } enabled, reason := m.applyPolicy(*status.Manifest) if !enabled { status.ApplyEnabled, status.ApplyDisabledReason = false, reason return status, &Error{Code: "update_apply_unsupported", Message: reason} } select { case <-ctx.Done(): return status, &Error{Code: "update_interrupted", Message: "apply request was cancelled before recovery was armed"} default: } packagePath := filepath.Join(m.validated, status.OperationID+".pkg") manifest, signer, err := VerifyPackage(packagePath, m.trustDirectory, m.product, m.profile, m.currentVersion) if err != nil || manifest.PackageID != status.Manifest.PackageID || signer != status.SignerKeyID { reason := "validated package identity changed before apply" if err != nil { reason = err.Error() } return m.fail(status.OperationID, "update_revalidation_failed", reason) } status.State = StateApplyPending status.Message = "Recovery apply accepted; synchronizing durable request state" status.ProgressPercent = 100 status.ApplyEnabled = false status.ApplyDisabledReason = "Recovery apply is already pending" status.RecoveryStep = "request-pending" status.UpdatedAt = m.clock().UTC() if err := m.setStatus(status, true); err != nil { return status, &Error{Code: "update_journal_failed", Message: err.Error()} } if err := m.requestRecovery(status.OperationID); err != nil { return m.fail(status.OperationID, "update_recovery_request_failed", err.Error()) } status.State = StateRebootPending status.Message = "Recovery request is durable; rebooting into the offline updater" status.RecoveryStep = "reboot-pending" status.UpdatedAt = m.clock().UTC() if err := m.setStatus(status, true); err != nil { return status, &Error{Code: "update_journal_failed", Message: err.Error()} } if err := m.reboot(); err != nil { status.RecoveryInstructions = "The recovery request is already set. Use the approved service procedure to reset the device; do not clear misc or remove the validated package." _ = m.setStatus(status, true) return cloneStatus(status), &Error{Code: "update_reboot_failed", Message: err.Error()} } return cloneStatus(status), nil } func (m *Manager) applyPolicy(manifest Manifest) (bool, string) { if manifest.Apply.Capability != ApplyCapabilityRecoveryV1 { return false, "This package is validation-only and cannot change running firmware" } if !m.recoveryApplyEnabled { return false, "Recovery Apply remains locked until the target recovery boot, readback, fault-injection, and service-path gates are approved" } return true, "" } func (m *Manager) recover() error { entries, err := os.ReadDir(m.incoming) if err != nil { return fmt.Errorf("scan update incoming directory: %w", err) } for _, entry := range entries { if entry.Type().IsRegular() && (strings.HasSuffix(entry.Name(), ".part") || strings.HasSuffix(entry.Name(), ".pkg")) { if err := os.Remove(filepath.Join(m.incoming, entry.Name())); err != nil { return fmt.Errorf("remove interrupted update staging: %w", err) } } } if data, err := os.ReadFile(m.journalPath); err == nil { if err := decodeSingleJSON(data, &m.status); err != nil { return fmt.Errorf("decode update journal: %w", err) } if m.status.State == StateReceiving || m.status.State == StateStaged || m.status.State == StateValidating { m.status.State = StateFailed m.status.Message = "Interrupted update operation recovered" m.status.FailureCode = "update_interrupted" m.status.FailureReason = "The updater restarted before validation completed; the partial package was removed" m.status.RecoveryInstructions = "Upload the signed local package again. The running firmware was not changed." m.status.UpdatedAt = m.clock().UTC() return m.persistLocked() } if m.status.State == StateValidated && m.status.Manifest != nil { if m.status.DeviceVersion == "" { m.status.DeviceVersion = m.currentVersion } m.status.ApplyEnabled, m.status.ApplyDisabledReason = m.applyPolicy(*m.status.Manifest) return m.persistLocked() } return nil } else if !os.IsNotExist(err) { return fmt.Errorf("read update journal: %w", err) } now := m.clock().UTC() m.status = Status{FormatVersion: 1, State: StateIdle, Message: "No firmware package has been uploaded", DeviceVersion: m.currentVersion, ApplyEnabled: false, ApplyDisabledReason: "Upload and validate a signed local package first", RecoveryInstructions: "No recovery action is required.", UpdatedAt: now} return m.persistLocked() } func (m *Manager) fail(operationID, code, reason string, cleanupPaths ...string) (Status, error) { for _, cleanupPath := range cleanupPaths { if cleanupPath != "" { _ = os.Remove(cleanupPath) } } m.mu.Lock() status := m.status m.mu.Unlock() status.OperationID = operationID status.State = StateFailed status.Message = "Firmware package validation did not complete" status.ProgressPercent = 0 status.ApplyEnabled = false status.ApplyDisabledReason = "Only a successfully validated package can proceed" status.FailureCode = code status.FailureReason = reason status.RecoveryInstructions = "Correct or replace the signed package and upload it again. The running firmware was not changed." status.UpdatedAt = m.clock().UTC() if snapshot, snapshotErr := m.governor.Snapshot(); snapshotErr == nil { status.Capacity = snapshot } _ = m.setStatus(status, true) return cloneStatus(status), &Error{Code: code, Message: reason} } func (m *Manager) transition(state, message string, progress int) Status { m.mu.Lock() defer m.mu.Unlock() status := cloneStatus(m.status) status.State, status.Message, status.ProgressPercent, status.UpdatedAt = state, message, progress, m.clock().UTC() return status } func (m *Manager) updateProgress(received int64) { m.mu.Lock() defer m.mu.Unlock() m.status.BytesReceived = received if m.status.BytesTotal > 0 { m.status.ProgressPercent = int(received * 75 / m.status.BytesTotal) } m.status.UpdatedAt = m.clock().UTC() } func (m *Manager) setStatus(status Status, persist bool) error { m.mu.Lock() defer m.mu.Unlock() m.status = cloneStatus(status) if persist { return m.persistLocked() } return nil } func (m *Manager) persistLocked() error { data, err := json.MarshalIndent(m.status, "", " ") if err != nil { return fmt.Errorf("encode update journal: %w", err) } if err := atomicfile.Write(m.journalPath, append(data, '\n'), 0o600); err != nil { return fmt.Errorf("persist update journal: %w", err) } return nil } func (m *Manager) removeOlderValidated(keep string) { entries, err := os.ReadDir(m.validated) if err != nil { return } for _, entry := range entries { candidate := filepath.Join(m.validated, entry.Name()) if candidate != keep && entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".pkg") { _ = os.Remove(candidate) } } } func requireDirectory(path string) error { info, err := os.Lstat(path) if err != nil { return fmt.Errorf("inspect required directory %s: %w", path, err) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("required path is not a safe directory: %s", path) } return nil } func randomID() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return "", err } return "fw-" + hex.EncodeToString(value), nil } func cloneStatus(value Status) Status { if value.Manifest != nil { manifest := *value.Manifest manifest.Payloads = append([]Payload(nil), value.Manifest.Payloads...) value.Manifest = &manifest } return value } type contextReader struct { ctx context.Context reader io.Reader } func (r *contextReader) Read(buffer []byte) (int, error) { select { case <-r.ctx.Done(): return 0, r.ctx.Err() default: return r.reader.Read(buffer) } } type progressWriter struct { writer io.Writer received int64 onProgress func(int64) } func (w *progressWriter) Write(buffer []byte) (int, error) { n, err := w.writer.Write(buffer) w.received += int64(n) if w.onProgress != nil { w.onProgress(w.received) } return n, err } // FactoryReset removes uploaded packages and their journal, not installed // firmware or the firmware-signing trust anchors. func (m *Manager) FactoryReset(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() if err := ctx.Err(); err != nil { return err } if m.active || m.status.State == StateRebootPending || m.status.State == StateApplying { return errors.New("cannot factory reset during firmware update") } for _, dir := range []string{m.incoming, m.validated, filepath.Dir(m.journalPath)} { if err := requireDirectory(dir); err != nil { return err } entries, err := os.ReadDir(dir) if err != nil { return err } for _, entry := range entries { path := filepath.Join(dir, entry.Name()) if filepath.Dir(path) != dir || entry.IsDir() { return errors.New("unexpected firmware staging entry") } if err := os.Remove(path); err != nil { return err } } if err := syncDirectory(dir); err != nil { return err } } m.status = Status{FormatVersion: 1, State: StateIdle, Message: "No firmware package has been uploaded", DeviceVersion: m.currentVersion, UpdatedAt: m.clock().UTC()} return m.persistLocked() }