// Package lifecycle coordinates installation-wide mutating operations. package lifecycle import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "sync" "time" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/safeio" ) var ( ErrLocked = errors.New("another lifecycle operation is already running for this installation") ErrOwnership = errors.New("lifecycle lock ownership changed; refusing to remove it") ErrTransactionInactive = errors.New("lifecycle transaction capability is not active") ErrTransactionInstallation = errors.New("lifecycle transaction capability belongs to another installation") ) const lockFileName = "lifecycle.lock.owner.json" type owner struct { Token string `json:"token"` PID int `json:"pid"` CreatedAt time.Time `json:"created_at"` } // Lock is an exclusively-created owner file scoped to one installation descriptor. type Lock struct { path string token string mu sync.Mutex released bool } // Transaction is an opaque, installation-bound capability for work that must run while a // lifecycle lock remains owned. Its fields are deliberately private so callers can obtain one // only through AcquireTransaction. type Transaction struct { lock *Lock controlDirectory string } // Acquire obtains the shared lock used by backup, restore, Pi lifecycle and product updates. func Acquire(installation config.Installation) (*Lock, error) { directory := installation.ControlDirectory() // The lifecycle directory is also the restore staging parent. Protect both the shared // .tht directory and this installation's child before any lock or staging artifact is // created; chmod alone does not install an owner-only DACL on Windows. if err := ensurePrivateLifecycleDirectory(filepath.Dir(directory)); err != nil { return nil, fmt.Errorf("protect lifecycle control parent: %w", err) } if err := ensurePrivateLifecycleDirectory(directory); err != nil { return nil, fmt.Errorf("protect lifecycle control directory: %w", err) } info, err := os.Lstat(directory) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("lifecycle control directory is not a regular directory") } tokenBytes := make([]byte, 16) if _, err := rand.Read(tokenBytes); err != nil { return nil, fmt.Errorf("create lifecycle lock owner: %w", err) } token := hex.EncodeToString(tokenBytes) path := filepath.Join(directory, lockFileName) file, err := safeio.CreateCanonicalNewPrivateFile(path) if err != nil { if _, statErr := os.Lstat(path); statErr == nil { return nil, ErrLocked } return nil, fmt.Errorf("acquire lifecycle lock: %w", err) } value := owner{Token: token, PID: os.Getpid(), CreatedAt: time.Now().UTC()} encodeErr := json.NewEncoder(file).Encode(value) if encodeErr == nil { encodeErr = file.Sync() } closeErr := file.Close() if encodeErr != nil || closeErr != nil { _ = os.Remove(path) return nil, fmt.Errorf("persist lifecycle lock owner: %w", errors.Join(encodeErr, closeErr)) } return &Lock{path: path, token: token}, nil } // ensurePrivateLifecycleDirectory repairs an existing directory's protection or creates the // final missing component with the platform's owner-only primitive. It deliberately does not // use os.MkdirAll for the security-sensitive path: safeio validates every canonical ancestor. func ensurePrivateLifecycleDirectory(path string) error { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { if err := safeio.EnsurePrivateDirectory(path); err != nil { return err } } else if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return safeio.ErrUnsafeFile } else { if err := safeio.ProtectPrivateDirectory(path); err != nil { return err } } return safeio.ValidatePrivateDirectory(path) } // AcquireTransaction obtains a lifecycle lock and returns the capability required by callers // that perform nested work inside the same non-reentrant transaction. func AcquireTransaction(installation config.Installation) (*Transaction, error) { lock, err := Acquire(installation) if err != nil { return nil, err } return &Transaction{ lock: lock, controlDirectory: filepath.Clean(installation.ControlDirectory()), }, nil } // Verify refuses a nil, released, replaced, or foreign-installation capability before a nested // lifecycle operation can begin. func (transaction *Transaction) Verify(installation config.Installation) error { if transaction == nil || transaction.lock == nil { return ErrTransactionInactive } if transaction.controlDirectory != filepath.Clean(installation.ControlDirectory()) { return ErrTransactionInstallation } return transaction.lock.verifyHeld() } // Release relinquishes the lifecycle lock associated with this transaction capability. func (transaction *Transaction) Release() error { if transaction == nil || transaction.lock == nil { return nil } return transaction.lock.Release() } // Path returns the installation-private owner-file path for diagnostics and tests. func (lock *Lock) Path() string { if lock == nil { return "" } return lock.path } // Release removes only the owner file created by this Lock. func (lock *Lock) Release() error { if lock == nil { return nil } lock.mu.Lock() defer lock.mu.Unlock() if lock.released { return nil } contents, err := os.ReadFile(lock.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return ErrOwnership } return fmt.Errorf("read lifecycle lock owner: %w", err) } var current owner if json.Unmarshal(contents, ¤t) != nil || current.Token == "" || current.Token != lock.token { return ErrOwnership } if err := os.Remove(lock.path); err != nil { return fmt.Errorf("release lifecycle lock: %w", err) } lock.released = true return nil } func (lock *Lock) verifyHeld() error { if lock == nil { return ErrTransactionInactive } lock.mu.Lock() defer lock.mu.Unlock() if lock.released { return ErrTransactionInactive } contents, err := os.ReadFile(lock.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return ErrOwnership } return fmt.Errorf("read lifecycle lock owner: %w", err) } var current owner if json.Unmarshal(contents, ¤t) != nil || current.Token == "" || current.Token != lock.token { return ErrOwnership } return nil }