Recognize local app states (T-202)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Package storage persists local SoftBox JSON state without UI dependencies.
|
||||
package storage
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user