package installer import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" ) const ( transactionFileName = "install-transaction.json" transactionBackupFileName = "install-transaction.json.backup" transactionSchemaVersion = 1 ) type transactionPhase string const ( phasePrepared transactionPhase = "prepared" phaseCurrentBackedUp transactionPhase = "current_backed_up" phaseStagingActivated transactionPhase = "staging_activated" phaseRollbackRequired transactionPhase = "rollback_required" phaseCommitted transactionPhase = "committed" ) var ( ErrTransactionCorrupt = errors.New("install transaction is corrupt") ErrRecoveryRequired = errors.New("install recovery is required") ) type transactionRecord struct { SchemaVersion int `json:"schema_version"` Phase transactionPhase `json:"phase"` HadCurrent bool `json:"had_current"` } func newTransaction(phase transactionPhase, hadCurrent bool) transactionRecord { return transactionRecord{ SchemaVersion: transactionSchemaVersion, Phase: phase, HadCurrent: hadCurrent, } } func (record transactionRecord) validate() error { if record.SchemaVersion != transactionSchemaVersion { return fmt.Errorf( "%w: schema_version=%d", ErrTransactionCorrupt, record.SchemaVersion, ) } switch record.Phase { case phasePrepared, phaseCurrentBackedUp, phaseStagingActivated, phaseRollbackRequired, phaseCommitted: return nil default: return fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase) } } func writeTransaction(layout appLayout, record transactionRecord) error { if err := record.validate(); err != nil { return err } data, err := json.Marshal(record) if err != nil { return fmt.Errorf("encode install transaction: %w", err) } data = append(data, '\n') if err := replaceFileWithBackup( layout.root, layout.transaction, layout.transactionBackup, data, ); err != nil { return fmt.Errorf("write install transaction: %w", err) } return nil } func loadTransaction(layout appLayout) (transactionRecord, bool, error) { data, err := readTransactionFile(layout.transaction) if os.IsNotExist(err) { data, err = readTransactionFile(layout.transactionBackup) } if os.IsNotExist(err) { return transactionRecord{}, false, nil } if err != nil { return transactionRecord{}, false, fmt.Errorf("read install transaction: %w", err) } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var record transactionRecord if err := decoder.Decode(&record); err != nil { return transactionRecord{}, false, fmt.Errorf("%w: %v", ErrTransactionCorrupt, err) } if err := ensureJSONEOF(decoder); err != nil { return transactionRecord{}, false, err } if err := record.validate(); err != nil { return transactionRecord{}, false, err } return record, true, nil } func readTransactionFile(path string) ([]byte, error) { info, err := os.Lstat(path) if err != nil { return nil, err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return nil, fmt.Errorf("%w: transaction is not a regular file", ErrUnsafeInstallLayout) } return os.ReadFile(path) } func ensureJSONEOF(decoder *json.Decoder) error { var extra any if err := decoder.Decode(&extra); err != io.EOF { if err == nil { return fmt.Errorf("%w: trailing JSON value", ErrTransactionCorrupt) } return fmt.Errorf("%w: trailing data: %v", ErrTransactionCorrupt, err) } return nil } func removeTransaction(layout appLayout) error { if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove install transaction: %w", err) } if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove install transaction backup: %w", err) } return nil } func replaceFileWithBackup( directory string, target string, backup string, data []byte, ) error { temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp") if err != nil { return err } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(0o600); err != nil { temporary.Close() return err } if _, err := temporary.Write(data); err != nil { temporary.Close() return err } if err := temporary.Sync(); err != nil { temporary.Close() return err } if err := temporary.Close(); err != nil { return err } movedTarget := false if info, err := os.Lstat(target); err == nil { if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return fmt.Errorf("%w: transaction target is not a regular file", ErrUnsafeInstallLayout) } if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { return err } if err := os.Rename(target, backup); err != nil { return err } movedTarget = true } else if !os.IsNotExist(err) { return err } if err := os.Rename(temporaryPath, target); err != nil { if movedTarget { _ = os.Rename(backup, target) } return err } if movedTarget { if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { return err } } return nil } func transactionPaths(root string) (string, string) { return filepath.Join(root, transactionFileName), filepath.Join(root, transactionBackupFileName) }