package updater import ( "bytes" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "time" ) type healthRecord struct { SchemaVersion int `json:"schema_version"` RequestID string `json:"request_id"` } // FileHealthWaiter reads the fixed root health file without accepting a // caller-provided locator. type FileHealthWaiter struct { PollInterval time.Duration } // WaitForHealth waits for a matching acknowledgement below target's root. func (waiter FileHealthWaiter) WaitForHealth(ctx context.Context, targetDir, requestID string, timeout time.Duration) error { layout, err := inspectTarget(targetDir) if err != nil { return err } if !validRequestID(requestID) { return fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest) } interval := waiter.PollInterval if interval <= 0 { interval = 250 * time.Millisecond } timer := time.NewTimer(timeout) defer timer.Stop() ticker := time.NewTicker(interval) defer ticker.Stop() for { record, readErr := readHealth(layout.health) if readErr == nil { if record.RequestID != requestID { return fmt.Errorf("%w: request ID does not match", ErrHealthInvalid) } return nil } if !os.IsNotExist(readErr) { return readErr } select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return ErrHealthTimeout case <-ticker.C: } } } // AcknowledgeHealthFromExecutable writes the acknowledgement only when the // running executable is exactly /app/SoftBox.exe. func AcknowledgeHealthFromExecutable(executablePath, requestID string, syncer DirectorySyncer) error { if syncer == nil { return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest) } if !validRequestID(requestID) { return fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest) } if !filepath.IsAbs(executablePath) || filepath.Base(filepath.Clean(executablePath)) != ProductExecutableName { return fmt.Errorf("%w: executable is not SoftBox.exe", ErrUnsafeLayout) } appDir := filepath.Dir(filepath.Clean(executablePath)) if filepath.Base(appDir) != appDirectoryName { return fmt.Errorf("%w: executable is outside root/app", ErrUnsafeLayout) } layout, err := inspectTarget(appDir) if err != nil { return err } if err := requireRealDirectory(layout.target, "target"); err != nil { return err } if err := validateRegularFile(executablePath); err != nil { return fmt.Errorf("%w: %v", ErrUnsafeLayout, err) } transaction, exists, err := loadTransaction(layout) if err != nil { return err } if !exists || transaction.RequestID != requestID || (transaction.Phase != phaseStagingActivated && transaction.Phase != phaseLaunched) { return fmt.Errorf("%w: no matching activated transaction", ErrHealthInvalid) } record := healthRecord{SchemaVersion: transactionSchemaVersion, RequestID: requestID} data, err := json.Marshal(record) if err != nil { return err } data = append(data, '\n') return replaceRegularFile(layout.root, layout.health, ".self-update-health-*.tmp", data, syncer) } func readHealth(path string) (healthRecord, error) { data, err := readRegularFile(path) if err != nil { return healthRecord{}, err } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var record healthRecord if err := decoder.Decode(&record); err != nil { return healthRecord{}, fmt.Errorf("%w: %v", ErrHealthInvalid, err) } var extra interface{} if err := decoder.Decode(&extra); err != io.EOF { return healthRecord{}, ErrHealthInvalid } if record.SchemaVersion != transactionSchemaVersion || !validRequestID(record.RequestID) { return healthRecord{}, ErrHealthInvalid } return record, nil }