From 819b1cdf88c8bef41d303f7568ec90314e27b120 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 17:00:06 +0800 Subject: [PATCH] Recognize local app states (T-202) --- core/catalog/parser.go | 7 +- core/catalog/schema_test.go | 6 +- core/domain/semver.go | 212 +++++++++++++ core/domain/semver_test.go | 79 +++++ core/domain/status_resolver.go | 73 +++++ core/domain/status_resolver_test.go | 90 ++++++ core/storage/doc.go | 2 + core/storage/installed_app.go | 449 ++++++++++++++++++++++++++++ core/storage/installed_app_test.go | 205 +++++++++++++ docs/04-architecture.md | 7 + docs/api.md | 35 +++ docs/current-state.md | 20 +- docs/tasks/T-202.md | 68 +++++ schemas/app.schema.json | 2 +- schemas/installed-app.schema.json | 62 ++++ schemas/manifest.schema.json | 2 +- 16 files changed, 1303 insertions(+), 16 deletions(-) create mode 100644 core/domain/semver.go create mode 100644 core/domain/semver_test.go create mode 100644 core/domain/status_resolver.go create mode 100644 core/domain/status_resolver_test.go create mode 100644 core/storage/doc.go create mode 100644 core/storage/installed_app.go create mode 100644 core/storage/installed_app_test.go create mode 100644 docs/tasks/T-202.md create mode 100644 schemas/installed-app.schema.json diff --git a/core/catalog/parser.go b/core/catalog/parser.go index e7f1ea2..78ff3dd 100644 --- a/core/catalog/parser.go +++ b/core/catalog/parser.go @@ -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 { diff --git a/core/catalog/schema_test.go b/core/catalog/schema_test.go index 35af71e..c722dec 100644 --- a/core/catalog/schema_test.go +++ b/core/catalog/schema_test.go @@ -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 { diff --git a/core/domain/semver.go b/core/domain/semver.go new file mode 100644 index 0000000..5d261ae --- /dev/null +++ b/core/domain/semver.go @@ -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 + } +} diff --git a/core/domain/semver_test.go b/core/domain/semver_test.go new file mode 100644 index 0000000..57e8440 --- /dev/null +++ b/core/domain/semver_test.go @@ -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) + } + }) + } +} diff --git a/core/domain/status_resolver.go b/core/domain/status_resolver.go new file mode 100644 index 0000000..059ef4b --- /dev/null +++ b/core/domain/status_resolver.go @@ -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 + } +} diff --git a/core/domain/status_resolver_test.go b/core/domain/status_resolver_test.go new file mode 100644 index 0000000..6164dcc --- /dev/null +++ b/core/domain/status_resolver_test.go @@ -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) + } +} diff --git a/core/storage/doc.go b/core/storage/doc.go new file mode 100644 index 0000000..13fa039 --- /dev/null +++ b/core/storage/doc.go @@ -0,0 +1,2 @@ +// Package storage persists local SoftBox JSON state without UI dependencies. +package storage diff --git a/core/storage/installed_app.go b/core/storage/installed_app.go new file mode 100644 index 0000000..56bf078 --- /dev/null +++ b/core/storage/installed_app.go @@ -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//. +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 +} diff --git a/core/storage/installed_app_test.go b/core/storage/installed_app_test.go new file mode 100644 index 0000000..9fb408b --- /dev/null +++ b/core/storage/installed_app_test.go @@ -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", + }, + }, + } +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 1336fc6..4353476 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -131,6 +131,13 @@ soft_quay/ 每次状态写入使用临时文件 + 原子替换;崩溃后可识别 staging、backup 和未完成事务。 +T-202 将本地识别拆为两层: + +- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在。 +- `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。 + +磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。 + ### 4.3 事件模型 后台任务只发布事件(`DownloadStarted / DownloadProgress / DownloadPaused / DownloadCompleted / DownloadFailed` 等),UI 按 RequestID 和软件 ID 回填,见 [api.md](api.md) 事件合约。 diff --git a/docs/api.md b/docs/api.md index 4d07af9..e64ebed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -138,6 +138,41 @@ T-102 Phase 1 原型进一步固定: 记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps//`。 +```json +{ + "schema_version": 1, + "id": "json-parser", + "version": "1.4.2", + "architecture": "amd64", + "channel": "stable", + "files": [ + { + "path": "JsonParser.exe", + "size": 3456789, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] +} +``` + +规则: + +- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、安全相对路径以外的文件名、重复路径和非法 SHA-256 均拒绝。 +- `files` 必须是数组;v1 可以为空,完整文件清单由 T-302 安装整合时从已验证包写入。 +- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。 +- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。 + +### 2.4.1 本地可见状态推导 + +状态事实由后台/存储层预先收集,UI Layout 不扫描磁盘。单一可见状态按以下优先级推导: + +1. 存在 `install-transaction.json` 或其 backup → `rollback_pending`。 +2. 存在活跃任务状态 → `queued|downloading|verifying|extracting|installing|failed`。 +3. 已检测到进程运行 → `running`。 +4. Catalog 判定不可兼容 → `incompatible`。 +5. 无 installed-app.json → `not_installed`。 +6. 本地 SemVer 低于 Catalog → `update_available`;否则 → `installed`。Catalog 中已隐藏/下架且无可比较版本时,保留 `installed`。 + ### 2.5 安装切换事务(Phase 1 原型) `apps//install-transaction.json` 用于断电恢复: diff --git a/docs/current-state.md b/docs/current-state.md index da3dc6d..699223a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,36 +13,36 @@ ## 当前快照 - 日期:2026-07-16 -- 阶段:Phase 2 进行中;T-201 Catalog 正式接入已完成,下一步 T-202 本地安装状态识别 +- 阶段:Phase 2 进行中;T-201/T-202 已完成,下一步 T-203 主界面软件列表 - 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入 -- 生产代码:core 已有状态/事件、Catalog HTTPS 获取/验签/严格解析/缓存/目标过滤、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell -- 测试:core 覆盖 Catalog 恶意/结构/通道/HTTPS/过滤、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub -- 数据:`schemas/` 已有 manifest/app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵 +- 生产代码:core 已有状态/事件、Catalog 正式客户端、SemVer 与 12 状态推导、installed-app.json 原子存储、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell +- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub +- 数据:`schemas/` 已有 manifest/app.json/installed-app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;下一步按路线图落成并领取 T-202 +- 当前 blocker:无;下一步按路线图落成并领取 T-203 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201 已完成;T-202 待按路线图落成 | +| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201/T-202 已完成;T-203 待按路线图落成 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、正式 Catalog 客户端与 Phase 1 安装安全原型 | +| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、正式 Catalog、本地安装状态/存储与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell | | `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell | -| `schemas/` | 已建 | `manifest.schema.json` 与 `app.schema.json` | +| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json` 与 `installed-app.schema.json` | | `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 | ## 任务状态 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`、`T-202`。 - 正在进行:无。 -- 下一个可领取任务:按路线图落成并领取 `T-202 本地安装状态识别`。 +- 下一个可领取任务:按路线图落成并领取 `T-203 主界面软件列表`。 ## 当前可运行内容 diff --git a/docs/tasks/T-202.md b/docs/tasks/T-202.md new file mode 100644 index 0000000..d3bf6f7 --- /dev/null +++ b/docs/tasks/T-202.md @@ -0,0 +1,68 @@ +--- +id: T-202 +title: 本地安装状态识别 +phase: 2 +deps: [T-201, T-103] +status: DONE +created: 2026-07-16 +issue: null +context_ref: 2e21c9f327bc56ab719fafee7ca5303f90747b4c +claim_branch: null +work_branch: agent/codex/T-202 +write_paths: + - docs/tasks/T-202.md + - core/domain/ + - core/storage/ + - core/catalog/ + - schemas/ + - docs/api.md + - docs/04-architecture.md + - docs/current-state.md +--- + +## 问题 / 背景 + +Catalog 已能给出可信远端版本和兼容性,但客户端还不能从 `apps//installed-app.json` 恢复实际安装版本,也没有统一的 SemVer 比较与 12 种用户状态推导。若 UI 自行拼接磁盘、版本和任务状态,状态优先级会漂移且容易在 Layout 中引入 IO。 + +## 方案 + +1. 在 `core/domain` 实现 SemVer 2.0.0 解析/比较,正确处理 prerelease,忽略 build metadata 的排序影响。 +2. 在 `core/storage` 定义 installed-app.json v1 模型和严格校验,使用临时文件 + 同目录 backup 原子替换,主文件缺失时可读取中断遗留 backup。 +3. 本地 Inspector 识别安装记录与 `install-transaction.json`/backup 是否存在,只返回快照,不推导 UI 状态。 +4. 在 domain 建立纯 `ResolveAppStatus`:按恢复事务、活跃操作、运行状态、兼容性、本地版本与 Catalog 版本推导已有 12 种 AppStatus。 +5. Catalog 版本字段校验复用 domain SemVer,避免协议解析与本地比较出现两套版本规则。 +6. 落地 `schemas/installed-app.schema.json`,同步协议、架构与当前状态文档。 + +## 验收要点 + +- installed-app.json 可严格读写;未知字段、ID 不匹配、非法版本/架构/channel/文件路径/哈希被拒绝。 +- 写入使用同目录临时文件 + backup 替换;主文件缺失时能读取中断遗留 backup。 +- 未完成安装事务能被识别为 `rollback_pending` 输入事实。 +- SemVer 覆盖 major/minor/patch、prerelease precedence、build metadata 和非法格式。 +- 表驱动测试逐一得到 12 个 AppStatus;Catalog 版本更高时为 update_available。 +- Go 1.20 core vet/test、完整双目标闸门和治理校验通过。 + +## 边界(不改什么) + +- 不实现下载/安装任务持久化或进程检测;这些状态只消费后续用例提供的事实。 +- 不实现 Gio 列表或在 Layout 中扫描磁盘(T-203)。 +- 不修改 T-103 的切换/恢复策略;这里只读识别其持久事务文件。 +- 不引入第三方 SemVer 或存储依赖。 + +## 协作约束 + +未启用 Gitea;本任务在 `agent/codex/T-202` 分支串行执行。存储 IO 与状态推导必须分层,后续 UI 只能消费快照/状态结果。 + +## 执行记录 + +- 2026-07-16:在 `core/domain` 实现完整 SemVer 2.0.0 解析与 precedence 比较;支持任意长度数字、prerelease 排序,build metadata 不影响更新判断,非法前导零/标识符被拒绝。 +- 2026-07-16:Catalog 的 `min_box_version` 与 app `version` 校验改为复用 domain SemVer,消除远端解析与本地更新判断的双规则。 +- 2026-07-16:在 `core/storage` 建立 installed-app.json v1 模型、严格 reader/writer 与 Inspector;写入采用同目录临时文件 + backup 原子替换,主文件缺失时读取并重验 backup。 +- 2026-07-16:安装记录校验覆盖未知字段、路径 ID 不匹配、架构/channel、文件安全相对路径、Windows 大小写折叠重复路径、size 与 SHA-256;transaction 主文件或 backup 可被识别为恢复待处理事实。 +- 2026-07-16:增加纯 `ResolveAppStatus`,状态优先级为恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → SemVer 更新;表驱动测试逐一覆盖全部 12 个 AppStatus。 +- 2026-07-16:新增 `schemas/installed-app.schema.json`,同步三份 Schema 的严格 SemVer pattern,并更新 `docs/api.md`、`docs/04-architecture.md`、`docs/current-state.md`。 +- 定向验证通过:`go -C core test -count=1 ./domain ./storage ./catalog`。 +- Schema 语法验证通过:`python -m json.tool` 解析 manifest/app/installed-app 三份 Schema。 +- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。 +- 提交前检查通过:`git diff --check`。 +- 工作区中的未跟踪 `soft_quay.code-workspace` 与本任务无关,已保留且未纳入提交。 diff --git a/schemas/app.schema.json b/schemas/app.schema.json index 13a938e..28313bc 100644 --- a/schemas/app.schema.json +++ b/schemas/app.schema.json @@ -39,7 +39,7 @@ }, "version": { "type": "string", - "pattern": "^(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-]+)*)?$" + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" }, "channel": { "const": "stable" diff --git a/schemas/installed-app.schema.json b/schemas/installed-app.schema.json new file mode 100644 index 0000000..f7c0da2 --- /dev/null +++ b/schemas/installed-app.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://softbox.invalid/schemas/installed-app.schema.json", + "title": "SoftBox installed-app.json v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "version", + "architecture", + "channel", + "files" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" + }, + "architecture": { + "enum": ["386", "amd64"] + }, + "channel": { + "const": "stable" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/file" + } + } + }, + "$defs": { + "file": { + "type": "object", + "additionalProperties": false, + "required": ["path", "size", "sha256"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9A-Fa-f]{64}$" + } + } + } + } +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 63ae343..b52d414 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -39,7 +39,7 @@ "$defs": { "semver": { "type": "string", - "pattern": "^(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-]+)*)?$" + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" }, "ed25519Signature": { "type": "string",