Files
soft_quay/core/storage/installed_app.go
T

438 lines
12 KiB
Go

package storage
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"softbox.local/core/domain"
"softbox.local/core/internal/safepath"
)
const (
installedAppFileName = "installed-app.json"
installedAppBackupFileName = "installed-app.json.backup"
installedAppSchemaVersion = 1
transactionFileName = "install-transaction.json"
transactionBackupFileName = "install-transaction.json.backup"
)
var (
ErrInstalledAppInvalid = errors.New("installed app record is invalid")
ErrStorageLayoutUnsafe = errors.New("installed app storage layout is unsafe")
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
)
// InstalledFile records one installed payload file.
type InstalledFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
// InstalledApp is the local installed-app.json v1 protocol.
type InstalledApp struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Version string `json:"version"`
Architecture string `json:"architecture"`
Channel string `json:"channel"`
Files []InstalledFile `json:"files"`
}
// InstallationSnapshot contains disk facts without deriving UI status.
type InstallationSnapshot struct {
Record *InstalledApp
RecoveryPending bool
}
// InstalledAppStore atomically reads and writes records below apps/<id>/.
type InstalledAppStore struct {
appsRoot string
mu sync.Mutex
}
// NewInstalledAppStore creates a store rooted at the SoftBoxData apps folder.
func NewInstalledAppStore(appsRoot string) *InstalledAppStore {
return &InstalledAppStore{appsRoot: appsRoot}
}
// Write validates and atomically replaces one installed-app.json.
func (store *InstalledAppStore) Write(record InstalledApp) error {
store.mu.Lock()
defer store.mu.Unlock()
if err := record.validate(); err != nil {
return err
}
appRoot, err := store.ensureAppRoot(record.ID)
if err != nil {
return err
}
document, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("encode installed app record: %w", err)
}
document = append(document, '\n')
return replaceInstalledAppFile(
appRoot,
filepath.Join(appRoot, installedAppFileName),
filepath.Join(appRoot, installedAppBackupFileName),
document,
)
}
// Read returns one installed record. found is false when neither current nor
// crash-recovery backup exists.
func (store *InstalledAppStore) Read(appID string) (record InstalledApp, found bool, err error) {
store.mu.Lock()
defer store.mu.Unlock()
return store.readLocked(appID)
}
// Inspect reads the installed record and detects an unfinished install journal.
func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) {
store.mu.Lock()
defer store.mu.Unlock()
record, found, err := store.readLocked(appID)
if err != nil {
return InstallationSnapshot{}, err
}
pending, err := store.recoveryPendingLocked(appID)
if err != nil {
return InstallationSnapshot{}, err
}
snapshot := InstallationSnapshot{RecoveryPending: pending}
if found {
recordCopy := record
recordCopy.Files = append([]InstalledFile(nil), record.Files...)
snapshot.Record = &recordCopy
}
return snapshot, nil
}
func (store *InstalledAppStore) readLocked(
appID string,
) (record InstalledApp, found bool, err error) {
appRoot, exists, err := store.inspectAppRoot(appID)
if err != nil || !exists {
return InstalledApp{}, false, err
}
target := filepath.Join(appRoot, installedAppFileName)
backup := filepath.Join(appRoot, installedAppBackupFileName)
document, err := readRegularFile(target)
if os.IsNotExist(err) {
document, err = readRegularFile(backup)
}
if os.IsNotExist(err) {
return InstalledApp{}, false, nil
}
if err != nil {
return InstalledApp{}, false, err
}
decoder := json.NewDecoder(bytes.NewReader(document))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&record); err != nil {
return InstalledApp{}, false, fmt.Errorf("%w: decode: %v", ErrInstalledAppInvalid, err)
}
if err := ensureInstalledAppEOF(decoder); err != nil {
return InstalledApp{}, false, err
}
if err := record.validate(); err != nil {
return InstalledApp{}, false, err
}
if record.ID != appID {
return InstalledApp{}, false, fmt.Errorf(
"%w: record id %q does not match path id %q",
ErrInstalledAppInvalid,
record.ID,
appID,
)
}
return record, true, nil
}
func (store *InstalledAppStore) recoveryPendingLocked(appID string) (bool, error) {
appRoot, exists, err := store.inspectAppRoot(appID)
if err != nil || !exists {
return false, err
}
for _, name := range []string{transactionFileName, transactionBackupFileName} {
info, err := os.Lstat(filepath.Join(appRoot, name))
if os.IsNotExist(err) {
continue
}
if err != nil {
return false, fmt.Errorf("inspect install transaction: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return false, fmt.Errorf(
"%w: %s is not a regular file",
ErrStorageLayoutUnsafe,
name,
)
}
return true, nil
}
return false, nil
}
func (store *InstalledAppStore) ensureAppRoot(appID string) (string, error) {
if !appIDPattern.MatchString(appID) {
return "", fmt.Errorf("%w: invalid app id %q", ErrInstalledAppInvalid, appID)
}
if store.appsRoot == "" {
return "", fmt.Errorf("%w: empty apps root", ErrStorageLayoutUnsafe)
}
absoluteRoot, err := filepath.Abs(store.appsRoot)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrStorageLayoutUnsafe, err)
}
if err := os.MkdirAll(absoluteRoot, 0o700); err != nil {
return "", fmt.Errorf("create apps root: %w", err)
}
if err := requireRealDirectory(absoluteRoot); err != nil {
return "", err
}
appRoot := filepath.Join(absoluteRoot, appID)
if err := os.Mkdir(appRoot, 0o700); err != nil && !os.IsExist(err) {
return "", fmt.Errorf("create app root: %w", err)
}
if err := requireRealDirectory(appRoot); err != nil {
return "", err
}
return appRoot, nil
}
func (store *InstalledAppStore) inspectAppRoot(appID string) (string, bool, error) {
if !appIDPattern.MatchString(appID) {
return "", false, fmt.Errorf("%w: invalid app id %q", ErrInstalledAppInvalid, appID)
}
if store.appsRoot == "" {
return "", false, fmt.Errorf("%w: empty apps root", ErrStorageLayoutUnsafe)
}
absoluteRoot, err := filepath.Abs(store.appsRoot)
if err != nil {
return "", false, fmt.Errorf("%w: %v", ErrStorageLayoutUnsafe, err)
}
info, err := os.Lstat(absoluteRoot)
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("inspect apps root: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", false, fmt.Errorf("%w: apps root is not a real directory", ErrStorageLayoutUnsafe)
}
appRoot := filepath.Join(absoluteRoot, appID)
info, err = os.Lstat(appRoot)
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("inspect app root: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", false, fmt.Errorf("%w: app root is not a real directory", ErrStorageLayoutUnsafe)
}
return appRoot, true, nil
}
func (record InstalledApp) validate() error {
if record.SchemaVersion != installedAppSchemaVersion {
return fmt.Errorf(
"%w: schema_version=%d",
ErrInstalledAppInvalid,
record.SchemaVersion,
)
}
if !appIDPattern.MatchString(record.ID) {
return fmt.Errorf("%w: invalid id %q", ErrInstalledAppInvalid, record.ID)
}
if _, err := domain.ParseSemVer(record.Version); err != nil {
return fmt.Errorf("%w: version: %v", ErrInstalledAppInvalid, err)
}
if record.Architecture != "386" && record.Architecture != "amd64" {
return fmt.Errorf(
"%w: architecture=%q",
ErrInstalledAppInvalid,
record.Architecture,
)
}
if record.Channel != "stable" {
return fmt.Errorf("%w: channel=%q", ErrInstalledAppInvalid, record.Channel)
}
if record.Files == nil {
return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid)
}
seenPaths := make(map[string]struct{}, len(record.Files))
for index, installedFile := range record.Files {
if err := safepath.ValidateRelative(installedFile.Path); err != nil {
return fmt.Errorf(
"%w: files[%d].path=%q: %v",
ErrInstalledAppInvalid,
index,
installedFile.Path,
err,
)
}
if installedFile.Size < 0 {
return fmt.Errorf(
"%w: files[%d].size=%d",
ErrInstalledAppInvalid,
index,
installedFile.Size,
)
}
if !sha256Pattern.MatchString(installedFile.SHA256) {
return fmt.Errorf(
"%w: files[%d].sha256",
ErrInstalledAppInvalid,
index,
)
}
foldedPath := safepath.CollisionKey(installedFile.Path)
if _, exists := seenPaths[foldedPath]; exists {
return fmt.Errorf(
"%w: duplicate file path %q",
ErrInstalledAppInvalid,
installedFile.Path,
)
}
seenPaths[foldedPath] = struct{}{}
}
return nil
}
func requireRealDirectory(directory string) error {
info, err := os.Lstat(directory)
if err != nil {
return fmt.Errorf("inspect storage directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("%w: %s is not a real directory", ErrStorageLayoutUnsafe, directory)
}
return nil
}
func readRegularFile(filePath string) ([]byte, error) {
info, err := os.Lstat(filePath)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, fmt.Errorf("%w: %s is not a regular file", ErrStorageLayoutUnsafe, filePath)
}
return os.ReadFile(filePath)
}
func ensureInstalledAppEOF(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", ErrInstalledAppInvalid)
}
return fmt.Errorf("%w: trailing data: %v", ErrInstalledAppInvalid, err)
}
return nil
}
func replaceInstalledAppFile(directory, target, backup string, document []byte) error {
temporary, err := os.CreateTemp(directory, ".installed-app-*.tmp")
if err != nil {
return fmt.Errorf("create installed app temp file: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("protect installed app temp file: %w", err)
}
if _, err := temporary.Write(document); err != nil {
temporary.Close()
return fmt.Errorf("write installed app temp file: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("sync installed app temp file: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close installed app temp file: %w", err)
}
movedTarget := false
hadBackup := false
if info, err := os.Lstat(target); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%w: installed app target is not regular", ErrStorageLayoutUnsafe)
}
if err := removeRegularBackup(backup); err != nil {
return err
}
if err := os.Rename(target, backup); err != nil {
return fmt.Errorf("backup installed app record: %w", err)
}
movedTarget = true
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect installed app record: %w", err)
} else {
backupInfo, backupErr := os.Lstat(backup)
switch {
case backupErr == nil:
if backupInfo.Mode()&os.ModeSymlink != 0 || !backupInfo.Mode().IsRegular() {
return fmt.Errorf(
"%w: installed app backup is not regular",
ErrStorageLayoutUnsafe,
)
}
hadBackup = true
case !os.IsNotExist(backupErr):
return fmt.Errorf("inspect installed app backup: %w", backupErr)
}
}
if err := os.Rename(temporaryPath, target); err != nil {
if movedTarget {
_ = os.Rename(backup, target)
}
return fmt.Errorf("activate installed app record: %w", err)
}
if movedTarget || hadBackup {
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove installed app backup: %w", err)
}
}
return nil
}
func removeRegularBackup(backup string) error {
info, err := os.Lstat(backup)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("inspect installed app backup: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%w: installed app backup is not regular", ErrStorageLayoutUnsafe)
}
if err := os.Remove(backup); err != nil {
return fmt.Errorf("remove installed app backup: %w", err)
}
return nil
}