package storage import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "regexp" "strings" "sync" "softbox.local/core/domain" ) 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//. 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 !validInstalledPath(installedFile.Path) { return fmt.Errorf( "%w: files[%d].path=%q", ErrInstalledAppInvalid, index, installedFile.Path, ) } 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 := strings.ToLower(installedFile.Path) if _, exists := seenPaths[foldedPath]; exists { return fmt.Errorf( "%w: duplicate file path %q", ErrInstalledAppInvalid, installedFile.Path, ) } seenPaths[foldedPath] = struct{}{} } return nil } func validInstalledPath(value string) bool { if value == "" || strings.Contains(value, `\`) || strings.Contains(value, ":") { return false } cleaned := path.Clean(value) return cleaned == value && cleaned != "." && !strings.HasPrefix(cleaned, "/") && cleaned != ".." && !strings.HasPrefix(cleaned, "../") } 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 }