Recognize local app states (T-202)

This commit is contained in:
ila
2026-07-16 17:00:06 +08:00
parent 2e21c9f327
commit 819b1cdf88
16 changed files with 1303 additions and 16 deletions
+4 -3
View File
@@ -13,6 +13,8 @@ import (
"regexp"
"strings"
"time"
"softbox.local/core/domain"
)
var (
@@ -23,7 +25,6 @@ var (
var (
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
semVerPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
iconRefPattern = regexp.MustCompile(`^sha256:[0-9A-Fa-f]{64}$`)
)
@@ -97,7 +98,7 @@ func validateManifest(manifest Manifest, expectedChannel ManifestChannel) error
if err != nil || offset != 0 {
return invalidField("generated_at", "must be an RFC3339 UTC timestamp")
}
if !semVerPattern.MatchString(manifest.MinBoxVersion) {
if _, err := domain.ParseSemVer(manifest.MinBoxVersion); err != nil {
return invalidField("min_box_version", "must be SemVer")
}
if err := validateSignature(manifest.Signature); err != nil {
@@ -127,7 +128,7 @@ func validateApp(app App) error {
if strings.TrimSpace(app.Description) == "" {
return invalidField("description", "must not be empty")
}
if !semVerPattern.MatchString(app.Version) {
if _, err := domain.ParseSemVer(app.Version); err != nil {
return invalidField("version", "must be SemVer")
}
if app.Channel != ReleaseStable {
+5 -1
View File
@@ -8,7 +8,11 @@ import (
)
func TestProtocolSchemasAreValidJSONObjects(t *testing.T) {
for _, name := range []string{"manifest.schema.json", "app.schema.json"} {
for _, name := range []string{
"manifest.schema.json",
"app.schema.json",
"installed-app.schema.json",
} {
t.Run(name, func(t *testing.T) {
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
if err != nil {
+212
View File
@@ -0,0 +1,212 @@
package domain
import (
"errors"
"fmt"
"strings"
)
var ErrInvalidSemVer = errors.New("invalid semantic version")
// SemVersion is a parsed Semantic Version 2.0.0 value.
type SemVersion struct {
original string
major string
minor string
patch string
prerelease []string
build []string
}
// ParseSemVer parses a complete Semantic Version 2.0.0 string.
func ParseSemVer(value string) (SemVersion, error) {
if value == "" {
return SemVersion{}, fmt.Errorf("%w: empty value", ErrInvalidSemVer)
}
coreAndPrerelease := value
var build []string
if separator := strings.IndexByte(value, '+'); separator >= 0 {
if strings.IndexByte(value[separator+1:], '+') >= 0 {
return SemVersion{}, fmt.Errorf("%w: multiple build separators", ErrInvalidSemVer)
}
coreAndPrerelease = value[:separator]
var err error
build, err = parseIdentifiers(value[separator+1:], false)
if err != nil {
return SemVersion{}, err
}
}
core := coreAndPrerelease
var prerelease []string
if separator := strings.IndexByte(coreAndPrerelease, '-'); separator >= 0 {
core = coreAndPrerelease[:separator]
var err error
prerelease, err = parseIdentifiers(coreAndPrerelease[separator+1:], true)
if err != nil {
return SemVersion{}, err
}
}
parts := strings.Split(core, ".")
if len(parts) != 3 {
return SemVersion{}, fmt.Errorf("%w: core must contain major.minor.patch", ErrInvalidSemVer)
}
for _, part := range parts {
if !validNumericIdentifier(part, false) {
return SemVersion{}, fmt.Errorf("%w: invalid core identifier %q", ErrInvalidSemVer, part)
}
}
return SemVersion{
original: value,
major: parts[0],
minor: parts[1],
patch: parts[2],
prerelease: prerelease,
build: build,
}, nil
}
// String returns the original normalized-by-validation input.
func (version SemVersion) String() string {
return version.original
}
// Compare applies SemVer precedence. Build metadata does not affect ordering.
func (version SemVersion) Compare(other SemVersion) int {
if comparison := compareNumericText(version.major, other.major); comparison != 0 {
return comparison
}
if comparison := compareNumericText(version.minor, other.minor); comparison != 0 {
return comparison
}
if comparison := compareNumericText(version.patch, other.patch); comparison != 0 {
return comparison
}
if len(version.prerelease) == 0 && len(other.prerelease) == 0 {
return 0
}
if len(version.prerelease) == 0 {
return 1
}
if len(other.prerelease) == 0 {
return -1
}
count := len(version.prerelease)
if len(other.prerelease) < count {
count = len(other.prerelease)
}
for index := 0; index < count; index++ {
left := version.prerelease[index]
right := other.prerelease[index]
leftNumeric := allDigits(left)
rightNumeric := allDigits(right)
switch {
case leftNumeric && rightNumeric:
if comparison := compareNumericText(left, right); comparison != 0 {
return comparison
}
case leftNumeric:
return -1
case rightNumeric:
return 1
case left < right:
return -1
case left > right:
return 1
}
}
switch {
case len(version.prerelease) < len(other.prerelease):
return -1
case len(version.prerelease) > len(other.prerelease):
return 1
default:
return 0
}
}
// CompareSemVer parses and compares two version strings.
func CompareSemVer(left, right string) (int, error) {
leftVersion, err := ParseSemVer(left)
if err != nil {
return 0, err
}
rightVersion, err := ParseSemVer(right)
if err != nil {
return 0, err
}
return leftVersion.Compare(rightVersion), nil
}
func parseIdentifiers(value string, rejectNumericLeadingZero bool) ([]string, error) {
identifiers := strings.Split(value, ".")
if value == "" || len(identifiers) == 0 {
return nil, fmt.Errorf("%w: empty identifier list", ErrInvalidSemVer)
}
for _, identifier := range identifiers {
if identifier == "" {
return nil, fmt.Errorf("%w: empty identifier", ErrInvalidSemVer)
}
for _, character := range identifier {
if (character < '0' || character > '9') &&
(character < 'A' || character > 'Z') &&
(character < 'a' || character > 'z') &&
character != '-' {
return nil, fmt.Errorf(
"%w: invalid identifier %q",
ErrInvalidSemVer,
identifier,
)
}
}
if rejectNumericLeadingZero &&
allDigits(identifier) &&
!validNumericIdentifier(identifier, false) {
return nil, fmt.Errorf(
"%w: numeric prerelease identifier %q has a leading zero",
ErrInvalidSemVer,
identifier,
)
}
}
return identifiers, nil
}
func validNumericIdentifier(value string, allowLeadingZero bool) bool {
if value == "" || !allDigits(value) {
return false
}
return allowLeadingZero || len(value) == 1 || value[0] != '0'
}
func allDigits(value string) bool {
if value == "" {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func compareNumericText(left, right string) int {
switch {
case len(left) < len(right):
return -1
case len(left) > len(right):
return 1
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
+79
View File
@@ -0,0 +1,79 @@
package domain
import (
"errors"
"testing"
)
func TestSemVerPrecedence(t *testing.T) {
ordered := []string{
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-alpha.beta",
"1.0.0-beta",
"1.0.0-beta.2",
"1.0.0-beta.11",
"1.0.0-rc.1",
"1.0.0",
"1.0.1",
"1.1.0",
"2.0.0",
}
for index := 0; index < len(ordered)-1; index++ {
comparison, err := CompareSemVer(ordered[index], ordered[index+1])
if err != nil {
t.Fatalf("CompareSemVer() error = %v", err)
}
if comparison >= 0 {
t.Fatalf("%q should precede %q", ordered[index], ordered[index+1])
}
}
}
func TestSemVerIgnoresBuildMetadata(t *testing.T) {
comparison, err := CompareSemVer("1.2.3+build.1", "1.2.3+build.99")
if err != nil {
t.Fatalf("CompareSemVer() error = %v", err)
}
if comparison != 0 {
t.Fatalf("comparison = %d, want 0", comparison)
}
}
func TestSemVerSupportsLargeNumericIdentifiers(t *testing.T) {
comparison, err := CompareSemVer(
"999999999999999999999999999.0.0",
"1000000000000000000000000000.0.0",
)
if err != nil {
t.Fatalf("CompareSemVer() error = %v", err)
}
if comparison >= 0 {
t.Fatalf("comparison = %d, want negative", comparison)
}
}
func TestSemVerRejectsInvalidValues(t *testing.T) {
for _, value := range []string{
"",
"1",
"1.2",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-",
"1.2.3-alpha..1",
"1.2.3-01",
"1.2.3+",
"1.2.3+build..1",
"v1.2.3",
"1.2.3 alpha",
} {
t.Run(value, func(t *testing.T) {
_, err := ParseSemVer(value)
if !errors.Is(err, ErrInvalidSemVer) {
t.Fatalf("ParseSemVer(%q) error = %v, want %v", value, err, ErrInvalidSemVer)
}
})
}
}
+73
View File
@@ -0,0 +1,73 @@
package domain
import (
"errors"
"fmt"
)
var ErrInvalidStatusFacts = errors.New("invalid app status facts")
// AppStatusFacts are IO-free observations used to derive one visible state.
type AppStatusFacts struct {
Operation AppStatus
Running bool
RecoveryPending bool
Incompatible bool
InstalledVersion string
CatalogVersion string
}
// ResolveAppStatus derives the single user-visible state from local and
// background-operation facts.
func ResolveAppStatus(facts AppStatusFacts) (AppStatus, error) {
if facts.RecoveryPending {
return StatusRollbackPending, nil
}
if facts.Operation != "" {
if !validOperationStatus(facts.Operation) {
return "", fmt.Errorf(
"%w: operation %q",
ErrInvalidStatusFacts,
facts.Operation,
)
}
return facts.Operation, nil
}
if facts.Running {
return StatusRunning, nil
}
if facts.Incompatible {
return StatusIncompatible, nil
}
if facts.InstalledVersion == "" {
return StatusNotInstalled, nil
}
if _, err := ParseSemVer(facts.InstalledVersion); err != nil {
return "", fmt.Errorf("%w: installed version: %v", ErrInvalidStatusFacts, err)
}
if facts.CatalogVersion == "" {
return StatusInstalled, nil
}
comparison, err := CompareSemVer(facts.InstalledVersion, facts.CatalogVersion)
if err != nil {
return "", fmt.Errorf("%w: catalog version: %v", ErrInvalidStatusFacts, err)
}
if comparison < 0 {
return StatusUpdateAvailable, nil
}
return StatusInstalled, nil
}
func validOperationStatus(status AppStatus) bool {
switch status {
case StatusQueued,
StatusDownloading,
StatusVerifying,
StatusExtracting,
StatusInstalling,
StatusFailed:
return true
default:
return false
}
}
+90
View File
@@ -0,0 +1,90 @@
package domain
import (
"errors"
"testing"
)
func TestResolveAppStatusCoversAllStates(t *testing.T) {
tests := []struct {
name string
facts AppStatusFacts
want AppStatus
}{
{name: "not installed", want: StatusNotInstalled},
{name: "queued", facts: AppStatusFacts{Operation: StatusQueued}, want: StatusQueued},
{name: "downloading", facts: AppStatusFacts{Operation: StatusDownloading}, want: StatusDownloading},
{name: "verifying", facts: AppStatusFacts{Operation: StatusVerifying}, want: StatusVerifying},
{name: "extracting", facts: AppStatusFacts{Operation: StatusExtracting}, want: StatusExtracting},
{name: "installing", facts: AppStatusFacts{Operation: StatusInstalling}, want: StatusInstalling},
{
name: "installed",
facts: AppStatusFacts{
InstalledVersion: "1.2.0",
CatalogVersion: "1.2.0",
},
want: StatusInstalled,
},
{
name: "update available",
facts: AppStatusFacts{
InstalledVersion: "1.2.0",
CatalogVersion: "1.3.0",
},
want: StatusUpdateAvailable,
},
{name: "running", facts: AppStatusFacts{Running: true}, want: StatusRunning},
{name: "failed", facts: AppStatusFacts{Operation: StatusFailed}, want: StatusFailed},
{
name: "rollback pending",
facts: AppStatusFacts{RecoveryPending: true},
want: StatusRollbackPending,
},
{
name: "incompatible",
facts: AppStatusFacts{Incompatible: true},
want: StatusIncompatible,
},
}
seen := make(map[AppStatus]bool)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
status, err := ResolveAppStatus(test.facts)
if err != nil {
t.Fatalf("ResolveAppStatus() error = %v", err)
}
if status != test.want {
t.Fatalf("status = %q, want %q", status, test.want)
}
seen[status] = true
})
}
if len(seen) != 12 {
t.Fatalf("covered %d states, want 12", len(seen))
}
}
func TestResolveAppStatusPriorityAndValidation(t *testing.T) {
status, err := ResolveAppStatus(AppStatusFacts{
Operation: StatusDownloading,
Running: true,
RecoveryPending: true,
Incompatible: true,
})
if err != nil {
t.Fatalf("ResolveAppStatus() error = %v", err)
}
if status != StatusRollbackPending {
t.Fatalf("status = %q, want %q", status, StatusRollbackPending)
}
_, err = ResolveAppStatus(AppStatusFacts{Operation: StatusInstalled})
if !errors.Is(err, ErrInvalidStatusFacts) {
t.Fatalf("operation error = %v, want %v", err, ErrInvalidStatusFacts)
}
_, err = ResolveAppStatus(AppStatusFacts{InstalledVersion: "not-semver"})
if !errors.Is(err, ErrInvalidStatusFacts) {
t.Fatalf("version error = %v, want %v", err, ErrInvalidStatusFacts)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package storage persists local SoftBox JSON state without UI dependencies.
package storage
+449
View File
@@ -0,0 +1,449 @@
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/<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 !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
}
+205
View File
@@ -0,0 +1,205 @@
package storage
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
func TestInstalledAppStoreWriteReadAndBackupFallback(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := NewInstalledAppStore(appsRoot)
record := validInstalledApp()
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
loaded, found, err := store.Read(record.ID)
if err != nil {
t.Fatalf("Read() error = %v", err)
}
if !found || loaded.Version != record.Version || len(loaded.Files) != 1 {
t.Fatalf("Read() = %#v, %t", loaded, found)
}
appRoot := filepath.Join(appsRoot, record.ID)
target := filepath.Join(appRoot, installedAppFileName)
backup := filepath.Join(appRoot, installedAppBackupFileName)
if err := os.Rename(target, backup); err != nil {
t.Fatalf("simulate interrupted replacement: %v", err)
}
loaded, found, err = store.Read(record.ID)
if err != nil {
t.Fatalf("Read(backup) error = %v", err)
}
if !found || loaded.ID != record.ID {
t.Fatalf("Read(backup) = %#v, %t", loaded, found)
}
}
func TestInstalledAppStoreRejectsInvalidRecords(t *testing.T) {
tests := []struct {
name string
mutate func(*InstalledApp)
}{
{
name: "invalid id",
mutate: func(record *InstalledApp) {
record.ID = "../escape"
},
},
{
name: "invalid version",
mutate: func(record *InstalledApp) {
record.Version = "1.02.0"
},
},
{
name: "invalid architecture",
mutate: func(record *InstalledApp) {
record.Architecture = "arm64"
},
},
{
name: "invalid channel",
mutate: func(record *InstalledApp) {
record.Channel = "beta"
},
},
{
name: "unsafe file path",
mutate: func(record *InstalledApp) {
record.Files[0].Path = "../escape.exe"
},
},
{
name: "invalid hash",
mutate: func(record *InstalledApp) {
record.Files[0].SHA256 = "short"
},
},
{
name: "case folded duplicate",
mutate: func(record *InstalledApp) {
record.Files = append(record.Files, InstalledFile{
Path: "JSONPARSER.EXE",
Size: 42,
SHA256: record.Files[0].SHA256,
})
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
record := validInstalledApp()
test.mutate(&record)
err := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")).Write(record)
if !errors.Is(err, ErrInstalledAppInvalid) {
t.Fatalf("Write() error = %v, want %v", err, ErrInstalledAppInvalid)
}
})
}
}
func TestInstalledAppStoreRejectsUnknownFieldsAndIDMismatch(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
appRoot := filepath.Join(appsRoot, "json-parser")
if err := os.MkdirAll(appRoot, 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
record := validInstalledApp()
encoded, err := json.Marshal(record)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var object map[string]any
if err := json.Unmarshal(encoded, &object); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
object["unknown"] = true
encoded, err = json.Marshal(object)
if err != nil {
t.Fatalf("Marshal(object) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(appRoot, installedAppFileName),
encoded,
0o600,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
store := NewInstalledAppStore(appsRoot)
_, _, err = store.Read("json-parser")
if !errors.Is(err, ErrInstalledAppInvalid) {
t.Fatalf("Read(unknown) error = %v, want %v", err, ErrInstalledAppInvalid)
}
delete(object, "unknown")
object["id"] = "other-app"
encoded, err = json.Marshal(object)
if err != nil {
t.Fatalf("Marshal(mismatch) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(appRoot, installedAppFileName),
encoded,
0o600,
); err != nil {
t.Fatalf("WriteFile(mismatch) error = %v", err)
}
_, _, err = store.Read("json-parser")
if !errors.Is(err, ErrInstalledAppInvalid) {
t.Fatalf("Read(mismatch) error = %v, want %v", err, ErrInstalledAppInvalid)
}
}
func TestInstalledAppStoreInspectDetectsRecovery(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := NewInstalledAppStore(appsRoot)
record := validInstalledApp()
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
transactionPath := filepath.Join(appsRoot, record.ID, transactionFileName)
if err := os.WriteFile(transactionPath, []byte(`{"schema_version":1}`), 0o600); err != nil {
t.Fatalf("WriteFile(transaction) error = %v", err)
}
snapshot, err := store.Inspect(record.ID)
if err != nil {
t.Fatalf("Inspect() error = %v", err)
}
if snapshot.Record == nil || !snapshot.RecoveryPending {
t.Fatalf("snapshot = %#v", snapshot)
}
}
func TestInstalledAppStoreMissingRecord(t *testing.T) {
store := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
_, found, err := store.Read("json-parser")
if err != nil {
t.Fatalf("Read() error = %v", err)
}
if found {
t.Fatal("found = true, want false")
}
}
func validInstalledApp() InstalledApp {
return InstalledApp{
SchemaVersion: 1,
ID: "json-parser",
Version: "1.2.0",
Architecture: "amd64",
Channel: "stable",
Files: []InstalledFile{
{
Path: "JsonParser.exe",
Size: 42,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
},
},
}
}