Recognize local app states (T-202)
This commit is contained in:
@@ -13,6 +13,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -23,7 +25,6 @@ var (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
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}$`)
|
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
|
||||||
iconRefPattern = regexp.MustCompile(`^sha256:[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 {
|
if err != nil || offset != 0 {
|
||||||
return invalidField("generated_at", "must be an RFC3339 UTC timestamp")
|
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")
|
return invalidField("min_box_version", "must be SemVer")
|
||||||
}
|
}
|
||||||
if err := validateSignature(manifest.Signature); err != nil {
|
if err := validateSignature(manifest.Signature); err != nil {
|
||||||
@@ -127,7 +128,7 @@ func validateApp(app App) error {
|
|||||||
if strings.TrimSpace(app.Description) == "" {
|
if strings.TrimSpace(app.Description) == "" {
|
||||||
return invalidField("description", "must not be empty")
|
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")
|
return invalidField("version", "must be SemVer")
|
||||||
}
|
}
|
||||||
if app.Channel != ReleaseStable {
|
if app.Channel != ReleaseStable {
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestProtocolSchemasAreValidJSONObjects(t *testing.T) {
|
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) {
|
t.Run(name, func(t *testing.T) {
|
||||||
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
|
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,13 @@ soft_quay/
|
|||||||
|
|
||||||
每次状态写入使用临时文件 + 原子替换;崩溃后可识别 staging、backup 和未完成事务。
|
每次状态写入使用临时文件 + 原子替换;崩溃后可识别 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 事件模型
|
### 4.3 事件模型
|
||||||
|
|
||||||
后台任务只发布事件(`DownloadStarted / DownloadProgress / DownloadPaused / DownloadCompleted / DownloadFailed` 等),UI 按 RequestID 和软件 ID 回填,见 [api.md](api.md) 事件合约。
|
后台任务只发布事件(`DownloadStarted / DownloadProgress / DownloadPaused / DownloadCompleted / DownloadFailed` 等),UI 按 RequestID 和软件 ID 回填,见 [api.md](api.md) 事件合约。
|
||||||
|
|||||||
+35
@@ -138,6 +138,41 @@ T-102 Phase 1 原型进一步固定:
|
|||||||
|
|
||||||
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
|
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
|
||||||
|
|
||||||
|
```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 原型)
|
### 2.5 安装切换事务(Phase 1 原型)
|
||||||
|
|
||||||
`apps/<id>/install-transaction.json` 用于断电恢复:
|
`apps/<id>/install-transaction.json` 用于断电恢复:
|
||||||
|
|||||||
+10
-10
@@ -13,36 +13,36 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-16
|
- 日期: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 已实际接入
|
- 技术栈:根 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 正式客户端、SemVer 与 12 状态推导、installed-app.json 原子存储、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell
|
||||||
- 测试:core 覆盖 Catalog 恶意/结构/通道/HTTPS/过滤、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub
|
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub
|
||||||
- 数据:`schemas/` 已有 manifest/app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
|
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
|
||||||
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||||
- 当前 blocker:无;下一步按路线图落成并领取 T-202
|
- 当前 blocker:无;下一步按路线图落成并领取 T-203
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
| `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 双平台验证入口 |
|
| `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-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell |
|
||||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 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 假数据与恶意样例;后续任务继续扩展 |
|
| `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
|
|
||||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
任务状态以 `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 主界面软件列表`。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -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/<id>/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` 与本任务无关,已保留且未纳入提交。
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"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": {
|
"channel": {
|
||||||
"const": "stable"
|
"const": "stable"
|
||||||
|
|||||||
@@ -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}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"$defs": {
|
"$defs": {
|
||||||
"semver": {
|
"semver": {
|
||||||
"type": "string",
|
"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": {
|
"ed25519Signature": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
Reference in New Issue
Block a user