From 2e21c9f327bc56ab719fafee7ca5303f90747b4c Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 16:52:46 +0800 Subject: [PATCH] Integrate validated catalog loading (T-201) --- core/catalog/client.go | 48 +++ core/catalog/client_test.go | 60 ++++ core/catalog/filter.go | 58 ++++ core/catalog/filter_test.go | 80 +++++ core/catalog/http_fetcher.go | 105 ++++++ core/catalog/http_fetcher_test.go | 82 +++++ core/catalog/loader.go | 74 +++-- core/catalog/model.go | 113 +++++++ core/catalog/parser.go | 300 ++++++++++++++++++ core/catalog/parser_test.go | 151 +++++++++ core/catalog/schema_test.go | 29 ++ docs/04-architecture.md | 4 +- docs/api.md | 21 +- docs/current-state.md | 20 +- docs/tasks/T-201.md | 66 ++++ schemas/app.schema.json | 90 ++++++ schemas/manifest.schema.json | 174 ++++++++++ testdata/README.md | 1 + .../catalog/manifest-tampered-payload.json | 6 +- testdata/catalog/manifest-valid-payload.json | 8 +- 20 files changed, 1450 insertions(+), 40 deletions(-) create mode 100644 core/catalog/client.go create mode 100644 core/catalog/client_test.go create mode 100644 core/catalog/filter.go create mode 100644 core/catalog/filter_test.go create mode 100644 core/catalog/http_fetcher.go create mode 100644 core/catalog/http_fetcher_test.go create mode 100644 core/catalog/model.go create mode 100644 core/catalog/parser.go create mode 100644 core/catalog/parser_test.go create mode 100644 core/catalog/schema_test.go create mode 100644 docs/tasks/T-201.md create mode 100644 schemas/app.schema.json create mode 100644 schemas/manifest.schema.json diff --git a/core/catalog/client.go b/core/catalog/client.go new file mode 100644 index 0000000..bc1d6ed --- /dev/null +++ b/core/catalog/client.go @@ -0,0 +1,48 @@ +package catalog + +import "context" + +// Client composes signed loading, protocol validation and target filtering. +type Client struct { + loader *Loader + parser Parser + target Target +} + +// NewClient creates the formal Catalog loading path. Protocol validation is +// installed on Loader so invalid remote data cannot replace the usable cache. +func NewClient( + verifier Verifier, + fetcher Fetcher, + cache Cache, + target Target, +) *Client { + parser := Parser{ExpectedChannel: target.Channel} + return &Client{ + loader: NewLoader(verifier, fetcher, cache, parser), + parser: parser, + target: target, + } +} + +// Load returns a verified, validated and target-filtered Catalog. +func (client *Client) Load(ctx context.Context) (Catalog, error) { + result, err := client.loader.Load(ctx) + if err != nil { + return Catalog{}, err + } + manifest, err := client.parser.Parse(result.Document) + if err != nil { + return Catalog{}, err + } + entries, err := Filter(manifest, client.target) + if err != nil { + return Catalog{}, err + } + return Catalog{ + Manifest: manifest, + Entries: entries, + Source: result.Source, + Warning: result.Warning, + }, nil +} diff --git a/core/catalog/client_test.go b/core/catalog/client_test.go new file mode 100644 index 0000000..1b4a635 --- /dev/null +++ b/core/catalog/client_test.go @@ -0,0 +1,60 @@ +package catalog + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +func TestClientValidatesBeforeReplacingCache(t *testing.T) { + publicKey, privateKey := catalogTestKey() + verifier, err := NewVerifier(publicKey) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + + validPayload := readCatalogFixture(t, "manifest-valid-payload.json") + validDocument, _ := signCatalogPayload(t, validPayload, privateKey) + cache := &memoryCache{document: append([]byte(nil), validDocument...)} + + var invalidPayload map[string]any + if err := json.Unmarshal(validPayload, &invalidPayload); err != nil { + t.Fatalf("decode payload: %v", err) + } + invalidPayload["channel"] = "win7" + encodedInvalid, err := json.Marshal(invalidPayload) + if err != nil { + t.Fatalf("encode invalid payload: %v", err) + } + invalidDocument, _ := signCatalogPayload(t, encodedInvalid, privateKey) + + client := NewClient( + verifier, + FetchFunc(func(context.Context) ([]byte, error) { + return invalidDocument, nil + }), + cache, + Target{ + Channel: ChannelModern, + OS: Windows10, + Architecture: ArchitectureAMD64, + }, + ) + result, err := client.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if result.Source != SourceCache { + t.Fatalf("Source = %q, want %q", result.Source, SourceCache) + } + if !errors.Is(result.Warning, ErrChannelMismatch) { + t.Fatalf("Warning = %v, want %v", result.Warning, ErrChannelMismatch) + } + if cache.storeCalls != 0 { + t.Fatalf("Store() calls = %d, want 0", cache.storeCalls) + } + if len(result.Entries) != 1 || result.Entries[0].App.ID != "json-parser" { + t.Fatalf("Entries = %#v", result.Entries) + } +} diff --git a/core/catalog/filter.go b/core/catalog/filter.go new file mode 100644 index 0000000..9ba0e51 --- /dev/null +++ b/core/catalog/filter.go @@ -0,0 +1,58 @@ +package catalog + +import "fmt" + +// Filter returns visible entries for target while retaining incompatible apps +// with a stable reason and no package action. +func Filter(manifest Manifest, target Target) ([]Entry, error) { + if !target.Channel.valid() || + !target.OS.valid() || + !target.Architecture.valid() { + return nil, fmt.Errorf("%w: %+v", ErrUnsupportedTarget, target) + } + if manifest.Channel != target.Channel { + return nil, fmt.Errorf( + "%w: got %q, want %q", + ErrChannelMismatch, + manifest.Channel, + target.Channel, + ) + } + + entries := make([]Entry, 0, len(manifest.Apps)) + for _, app := range manifest.Apps { + if app.Status == CatalogStatusHidden { + continue + } + entry := Entry{App: app} + if app.Status == CatalogStatusDeprecated { + entry.Reason = ReasonDeprecated + entries = append(entries, entry) + continue + } + if !supportsOS(target.OS, app.MinOS) { + entry.Reason = ReasonMinimumOS + entries = append(entries, entry) + continue + } + publishedPackage, ok := app.Packages[target.Architecture] + if !ok { + entry.Reason = ReasonArchitecture + entries = append(entries, entry) + continue + } + entry.Package = &publishedPackage + entry.Installable = true + entries = append(entries, entry) + } + return entries, nil +} + +func supportsOS(target, minimum WindowsRelease) bool { + ranks := map[WindowsRelease]int{ + Windows7SP1: 7, + Windows10: 10, + Windows11: 11, + } + return ranks[target] >= ranks[minimum] +} diff --git a/core/catalog/filter_test.go b/core/catalog/filter_test.go new file mode 100644 index 0000000..9655b28 --- /dev/null +++ b/core/catalog/filter_test.go @@ -0,0 +1,80 @@ +package catalog + +import "testing" + +func TestFilterHandlesStatusOSAndArchitecture(t *testing.T) { + manifest := validManifestForTest() + active := manifest.Apps[0] + + deprecated := active + deprecated.ID = "deprecated-app" + deprecated.Status = CatalogStatusDeprecated + + hidden := active + hidden.ID = "hidden-app" + hidden.Status = CatalogStatusHidden + + newOS := active + newOS.ID = "windows-11-app" + newOS.MinOS = Windows11 + + wrongArchitecture := active + wrongArchitecture.ID = "x86-app" + wrongArchitecture.Architectures = []Architecture{Architecture386} + wrongArchitecture.Packages = map[Architecture]Package{ + Architecture386: active.Packages[ArchitectureAMD64], + } + manifest.Apps = []App{active, deprecated, hidden, newOS, wrongArchitecture} + + entries, err := Filter(manifest, Target{ + Channel: ChannelModern, + OS: Windows10, + Architecture: ArchitectureAMD64, + }) + if err != nil { + t.Fatalf("Filter() error = %v", err) + } + if len(entries) != 4 { + t.Fatalf("len(entries) = %d, want 4", len(entries)) + } + + assertEntry := func(index int, id string, installable bool, reason AvailabilityReason) { + t.Helper() + entry := entries[index] + if entry.App.ID != id || + entry.Installable != installable || + entry.Reason != reason { + t.Fatalf( + "entries[%d] = {%q %t %q}, want {%q %t %q}", + index, + entry.App.ID, + entry.Installable, + entry.Reason, + id, + installable, + reason, + ) + } + } + assertEntry(0, "json-parser", true, ReasonNone) + assertEntry(1, "deprecated-app", false, ReasonDeprecated) + assertEntry(2, "windows-11-app", false, ReasonMinimumOS) + assertEntry(3, "x86-app", false, ReasonArchitecture) +} + +func TestFilterAllowsWin7CompatibleAppOnModernWindows(t *testing.T) { + manifest := validManifestForTest() + manifest.Apps[0].MinOS = Windows7SP1 + + entries, err := Filter(manifest, Target{ + Channel: ChannelModern, + OS: Windows11, + Architecture: ArchitectureAMD64, + }) + if err != nil { + t.Fatalf("Filter() error = %v", err) + } + if len(entries) != 1 || !entries[0].Installable { + t.Fatalf("entries = %#v", entries) + } +} diff --git a/core/catalog/http_fetcher.go b/core/catalog/http_fetcher.go new file mode 100644 index 0000000..36317c4 --- /dev/null +++ b/core/catalog/http_fetcher.go @@ -0,0 +1,105 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const DefaultMaxManifestBytes int64 = 8 << 20 + +var ( + ErrInsecureCatalogURL = errors.New("catalog URL must use HTTPS") + ErrCatalogHTTPStatus = errors.New("catalog HTTP status is not successful") + ErrCatalogTooLarge = errors.New("catalog response exceeds size limit") +) + +// HTTPFetcher obtains a Catalog document over HTTPS with a bounded response. +type HTTPFetcher struct { + URL string + Client *http.Client + MaxBytes int64 +} + +// Fetch implements Fetcher. +func (fetcher HTTPFetcher) Fetch(ctx context.Context) ([]byte, error) { + parsedURL, err := url.Parse(fetcher.URL) + if err != nil { + return nil, fmt.Errorf("parse catalog URL: %w", err) + } + if err := validateFetchURL(parsedURL); err != nil { + return nil, err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("create catalog request: %w", err) + } + request.Header.Set("Accept", "application/json") + + client := fetcher.Client + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + clientCopy := *client + previousRedirectCheck := client.CheckRedirect + clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if err := validateFetchURL(request.URL); err != nil { + return err + } + if previousRedirectCheck != nil { + return previousRedirectCheck(request, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil + } + + response, err := clientCopy.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch catalog: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("%w: %s", ErrCatalogHTTPStatus, response.Status) + } + maxBytes := fetcher.MaxBytes + if maxBytes <= 0 { + maxBytes = DefaultMaxManifestBytes + } + if response.ContentLength > maxBytes { + return nil, fmt.Errorf( + "%w: content-length %d, limit %d", + ErrCatalogTooLarge, + response.ContentLength, + maxBytes, + ) + } + document, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read catalog response: %w", err) + } + if int64(len(document)) > maxBytes { + return nil, fmt.Errorf("%w: limit %d", ErrCatalogTooLarge, maxBytes) + } + return document, nil +} + +func validateFetchURL(parsedURL *url.URL) error { + if parsedURL == nil || + parsedURL.Scheme != "https" || + parsedURL.Host == "" || + parsedURL.User != nil { + return ErrInsecureCatalogURL + } + if parsedURL.Fragment != "" { + return fmt.Errorf("%w: fragments are not allowed", ErrInsecureCatalogURL) + } + return nil +} diff --git a/core/catalog/http_fetcher_test.go b/core/catalog/http_fetcher_test.go new file mode 100644 index 0000000..cfa2a95 --- /dev/null +++ b/core/catalog/http_fetcher_test.go @@ -0,0 +1,82 @@ +package catalog + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPFetcherRequiresHTTPSAndBoundsResponse(t *testing.T) { + t.Run("insecure URL", func(t *testing.T) { + _, err := (HTTPFetcher{URL: "http://example.invalid/manifest.json"}). + Fetch(context.Background()) + if !errors.Is(err, ErrInsecureCatalogURL) { + t.Fatalf("Fetch() error = %v, want %v", err, ErrInsecureCatalogURL) + } + }) + + t.Run("successful HTTPS", func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + if request.Header.Get("Accept") != "application/json" { + t.Errorf("Accept = %q", request.Header.Get("Accept")) + } + _, _ = writer.Write([]byte(`{"schema_version":1}`)) + })) + defer server.Close() + + document, err := (HTTPFetcher{ + URL: server.URL, + Client: server.Client(), + MaxBytes: 64, + }).Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + if string(document) != `{"schema_version":1}` { + t.Fatalf("document = %q", document) + } + }) + + t.Run("too large", func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func( + writer http.ResponseWriter, + _ *http.Request, + ) { + writer.Header().Set("Content-Length", "6") + _, _ = writer.Write([]byte("123456")) + })) + defer server.Close() + + _, err := (HTTPFetcher{ + URL: server.URL, + Client: server.Client(), + MaxBytes: 5, + }).Fetch(context.Background()) + if !errors.Is(err, ErrCatalogTooLarge) { + t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogTooLarge) + } + }) + + t.Run("HTTP error", func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func( + writer http.ResponseWriter, + _ *http.Request, + ) { + http.Error(writer, "unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + _, err := (HTTPFetcher{ + URL: server.URL, + Client: server.Client(), + }).Fetch(context.Background()) + if !errors.Is(err, ErrCatalogHTTPStatus) { + t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogHTTPStatus) + } + }) +} diff --git a/core/catalog/loader.go b/core/catalog/loader.go index c841056..227bf63 100644 --- a/core/catalog/loader.go +++ b/core/catalog/loader.go @@ -26,6 +26,19 @@ type Cache interface { Store([]byte) error } +// DocumentValidator rejects signed documents that this client cannot consume. +// Validators run before a remote document can replace the last valid cache. +type DocumentValidator interface { + Validate(VerifiedDocument) error +} + +// DocumentValidatorFunc adapts a function to DocumentValidator. +type DocumentValidatorFunc func(VerifiedDocument) error + +func (function DocumentValidatorFunc) Validate(document VerifiedDocument) error { + return function(document) +} + // LoadSource describes where a verified result came from. type LoadSource string @@ -57,16 +70,23 @@ func (err *LoadError) Unwrap() error { // Loader verifies remote data before storing it and re-verifies cache fallback. type Loader struct { - verifier Verifier - fetcher Fetcher - cache Cache + verifier Verifier + fetcher Fetcher + cache Cache + validators []DocumentValidator } -func NewLoader(verifier Verifier, fetcher Fetcher, cache Cache) *Loader { +func NewLoader( + verifier Verifier, + fetcher Fetcher, + cache Cache, + validators ...DocumentValidator, +) *Loader { return &Loader{ - verifier: verifier, - fetcher: fetcher, - cache: cache, + verifier: verifier, + fetcher: fetcher, + cache: cache, + validators: append([]DocumentValidator(nil), validators...), } } @@ -76,12 +96,15 @@ func (loader *Loader) Load(ctx context.Context) (LoadResult, error) { if refreshErr == nil { verified, verifyErr := loader.verifier.Verify(remoteBytes) if verifyErr == nil { - storeErr := loader.cache.Store(verified.Bytes) - return LoadResult{ - Document: verified, - Source: SourceRemote, - Warning: storeErr, - }, nil + verifyErr = loader.validate(verified) + if verifyErr == nil { + storeErr := loader.cache.Store(verified.Bytes) + return LoadResult{ + Document: verified, + Source: SourceRemote, + Warning: storeErr, + }, nil + } } refreshErr = verifyErr } @@ -91,11 +114,14 @@ func (loader *Loader) Load(ctx context.Context) (LoadResult, error) { var verified VerifiedDocument verified, cacheErr = loader.verifier.Verify(cachedBytes) if cacheErr == nil { - return LoadResult{ - Document: verified, - Source: SourceCache, - Warning: refreshErr, - }, nil + cacheErr = loader.validate(verified) + if cacheErr == nil { + return LoadResult{ + Document: verified, + Source: SourceCache, + Warning: refreshErr, + }, nil + } } } @@ -104,3 +130,15 @@ func (loader *Loader) Load(ctx context.Context) (LoadResult, error) { Cache: cacheErr, } } + +func (loader *Loader) validate(document VerifiedDocument) error { + for _, validator := range loader.validators { + if validator == nil { + continue + } + if err := validator.Validate(document); err != nil { + return err + } + } + return nil +} diff --git a/core/catalog/model.go b/core/catalog/model.go new file mode 100644 index 0000000..bb198fb --- /dev/null +++ b/core/catalog/model.go @@ -0,0 +1,113 @@ +package catalog + +// ManifestChannel separates modern and Win7 delivery tracks. +type ManifestChannel string + +const ( + ChannelModern ManifestChannel = "modern" + ChannelWin7 ManifestChannel = "win7" +) + +// ReleaseChannel is the app release track supported by the MVP. +type ReleaseChannel string + +const ( + ReleaseStable ReleaseChannel = "stable" +) + +// AppCatalogStatus controls catalog visibility and installability. +type AppCatalogStatus string + +const ( + CatalogStatusActive AppCatalogStatus = "active" + CatalogStatusDeprecated AppCatalogStatus = "deprecated" + CatalogStatusHidden AppCatalogStatus = "hidden" +) + +// Architecture identifies a Windows package architecture. +type Architecture string + +const ( + Architecture386 Architecture = "386" + ArchitectureAMD64 Architecture = "amd64" +) + +// WindowsRelease is an ordered minimum Windows release identifier. +type WindowsRelease string + +const ( + Windows7SP1 WindowsRelease = "windows-7-sp1" + Windows10 WindowsRelease = "windows-10" + Windows11 WindowsRelease = "windows-11" +) + +// Manifest is the signed Catalog protocol v1 document. +type Manifest struct { + SchemaVersion int `json:"schema_version"` + Channel ManifestChannel `json:"channel"` + GeneratedAt string `json:"generated_at"` + MinBoxVersion string `json:"min_box_version"` + Apps []App `json:"apps"` + Signature string `json:"signature"` +} + +// App is one published product in a Manifest. +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Channel ReleaseChannel `json:"channel"` + Status AppCatalogStatus `json:"status"` + Category string `json:"category"` + Tags []string `json:"tags"` + Icon string `json:"icon,omitempty"` + Homepage string `json:"homepage,omitempty"` + Tutorial string `json:"tutorial,omitempty"` + MinOS WindowsRelease `json:"min_os"` + Architectures []Architecture `json:"architectures"` + EntryEXE string `json:"entry_exe"` + RequiresAdmin bool `json:"requires_admin"` + Packages map[Architecture]Package `json:"packages"` +} + +// Package describes one downloadable ZIP artifact. +type Package struct { + URL string `json:"url"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Signature string `json:"signature"` +} + +// Target describes the client build and operating system consuming a Catalog. +type Target struct { + Channel ManifestChannel + OS WindowsRelease + Architecture Architecture +} + +// AvailabilityReason is stable data for UI localization and action gating. +type AvailabilityReason string + +const ( + ReasonNone AvailabilityReason = "" + ReasonDeprecated AvailabilityReason = "deprecated" + ReasonMinimumOS AvailabilityReason = "minimum_os" + ReasonArchitecture AvailabilityReason = "architecture" +) + +// Entry is a visible app after target filtering. +type Entry struct { + App App + Package *Package + Installable bool + Reason AvailabilityReason +} + +// Catalog is a verified, validated and target-filtered Manifest. +type Catalog struct { + Manifest Manifest + Entries []Entry + Source LoadSource + Warning error +} diff --git a/core/catalog/parser.go b/core/catalog/parser.go new file mode 100644 index 0000000..e7f1ea2 --- /dev/null +++ b/core/catalog/parser.go @@ -0,0 +1,300 @@ +package catalog + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "path" + "regexp" + "strings" + "time" +) + +var ( + ErrInvalidManifest = errors.New("invalid catalog manifest") + ErrChannelMismatch = errors.New("catalog channel mismatch") + ErrUnsupportedTarget = errors.New("unsupported catalog target") +) + +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}$`) +) + +// Parser validates the protocol shape for one delivery channel. +type Parser struct { + ExpectedChannel ManifestChannel +} + +// Validate implements DocumentValidator. +func (parser Parser) Validate(document VerifiedDocument) error { + _, err := parser.Parse(document) + return err +} + +// Parse strictly decodes a previously verified Manifest. +func (parser Parser) Parse(document VerifiedDocument) (Manifest, error) { + if !parser.ExpectedChannel.valid() { + return Manifest{}, fmt.Errorf( + "%w: channel %q", + ErrUnsupportedTarget, + parser.ExpectedChannel, + ) + } + + decoder := json.NewDecoder(bytes.NewReader(document.Bytes)) + decoder.DisallowUnknownFields() + decoder.UseNumber() + + var manifest Manifest + if err := decoder.Decode(&manifest); err != nil { + return Manifest{}, fmt.Errorf("%w: decode: %v", ErrInvalidManifest, err) + } + if err := consumeEOF(decoder); err != nil { + return Manifest{}, err + } + if err := validateManifest(manifest, parser.ExpectedChannel); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func consumeEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("%w: trailing JSON value", ErrInvalidManifest) + } + return fmt.Errorf("%w: trailing data: %v", ErrInvalidManifest, err) + } + return nil +} + +func validateManifest(manifest Manifest, expectedChannel ManifestChannel) error { + if manifest.SchemaVersion != 1 { + return invalidField("schema_version", "must be 1") + } + if !manifest.Channel.valid() { + return invalidField("channel", "unsupported value %q", manifest.Channel) + } + if manifest.Channel != expectedChannel { + return fmt.Errorf( + "%w: got %q, want %q", + ErrChannelMismatch, + manifest.Channel, + expectedChannel, + ) + } + generatedAt, err := time.Parse(time.RFC3339, manifest.GeneratedAt) + _, offset := generatedAt.Zone() + if err != nil || offset != 0 { + return invalidField("generated_at", "must be an RFC3339 UTC timestamp") + } + if !semVerPattern.MatchString(manifest.MinBoxVersion) { + return invalidField("min_box_version", "must be SemVer") + } + if err := validateSignature(manifest.Signature); err != nil { + return invalidField("signature", "%v", err) + } + + seenIDs := make(map[string]struct{}, len(manifest.Apps)) + for index, app := range manifest.Apps { + if err := validateApp(app); err != nil { + return fmt.Errorf("%w: apps[%d]: %v", ErrInvalidManifest, index, err) + } + if _, exists := seenIDs[app.ID]; exists { + return fmt.Errorf("%w: duplicate app id %q", ErrInvalidManifest, app.ID) + } + seenIDs[app.ID] = struct{}{} + } + return nil +} + +func validateApp(app App) error { + if !appIDPattern.MatchString(app.ID) { + return invalidField("id", "must match ^[a-z0-9-]+$") + } + if strings.TrimSpace(app.Name) == "" { + return invalidField("name", "must not be empty") + } + if strings.TrimSpace(app.Description) == "" { + return invalidField("description", "must not be empty") + } + if !semVerPattern.MatchString(app.Version) { + return invalidField("version", "must be SemVer") + } + if app.Channel != ReleaseStable { + return invalidField("channel", "unsupported value %q", app.Channel) + } + if !app.Status.valid() { + return invalidField("status", "unsupported value %q", app.Status) + } + if strings.TrimSpace(app.Category) == "" { + return invalidField("category", "must not be empty") + } + if len(app.Tags) == 0 { + return invalidField("tags", "must contain at least one tag") + } + for _, tag := range app.Tags { + if strings.TrimSpace(tag) == "" { + return invalidField("tags", "must not contain empty values") + } + } + if app.Icon != "" && !iconRefPattern.MatchString(app.Icon) { + return invalidField("icon", "must be sha256:<64 hexadecimal characters>") + } + if err := validateOptionalHTTPSURL("homepage", app.Homepage); err != nil { + return err + } + if err := validateOptionalHTTPSURL("tutorial", app.Tutorial); err != nil { + return err + } + if !app.MinOS.valid() { + return invalidField("min_os", "unsupported value %q", app.MinOS) + } + if len(app.Architectures) == 0 { + return invalidField("architectures", "must not be empty") + } + if !validSafeRelativePath(app.EntryEXE) { + return invalidField("entry_exe", "must be a safe relative path") + } + if len(app.Packages) == 0 { + return invalidField("packages", "must not be empty") + } + + architectures := make(map[Architecture]struct{}, len(app.Architectures)) + for _, architecture := range app.Architectures { + if !architecture.valid() { + return invalidField("architectures", "unsupported value %q", architecture) + } + if _, exists := architectures[architecture]; exists { + return invalidField("architectures", "duplicate value %q", architecture) + } + architectures[architecture] = struct{}{} + } + for architecture := range architectures { + publishedPackage, exists := app.Packages[architecture] + if !exists { + return invalidField("packages", "missing %q package", architecture) + } + if err := validatePackage(architecture, publishedPackage); err != nil { + return err + } + } + for architecture := range app.Packages { + if _, exists := architectures[architecture]; !exists { + return invalidField( + "packages", + "package %q is absent from architectures", + architecture, + ) + } + } + return nil +} + +func validatePackage(architecture Architecture, publishedPackage Package) error { + if !architecture.valid() { + return invalidField("packages", "unsupported key %q", architecture) + } + if err := validateHTTPSURL(publishedPackage.URL); err != nil { + return invalidField("packages."+string(architecture)+".url", "%v", err) + } + if publishedPackage.Size <= 0 { + return invalidField("packages."+string(architecture)+".size", "must be positive") + } + if !sha256Pattern.MatchString(publishedPackage.SHA256) { + return invalidField( + "packages."+string(architecture)+".sha256", + "must contain 64 hexadecimal characters", + ) + } + if _, err := hex.DecodeString(publishedPackage.SHA256); err != nil { + return invalidField("packages."+string(architecture)+".sha256", "%v", err) + } + if err := validateSignature(publishedPackage.Signature); err != nil { + return invalidField("packages."+string(architecture)+".signature", "%v", err) + } + return nil +} + +func validateSignature(value string) error { + signature, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil { + return fmt.Errorf("must be strict Base64: %v", err) + } + if len(signature) != 64 { + return fmt.Errorf("must decode to 64 bytes") + } + return nil +} + +func validateOptionalHTTPSURL(fieldName, value string) error { + if value == "" { + return nil + } + if err := validateHTTPSURL(value); err != nil { + return invalidField(fieldName, "%v", err) + } + return nil +} + +func validateHTTPSURL(value string) error { + parsed, err := url.Parse(value) + if err != nil { + return fmt.Errorf("invalid URL: %v", err) + } + if parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return errors.New("must be an absolute HTTPS URL without user information") + } + if parsed.Fragment != "" { + return errors.New("must not contain a fragment") + } + return nil +} + +func validSafeRelativePath(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 invalidField(field, format string, values ...any) error { + return fmt.Errorf( + "%w: %s: %s", + ErrInvalidManifest, + field, + fmt.Sprintf(format, values...), + ) +} + +func (channel ManifestChannel) valid() bool { + return channel == ChannelModern || channel == ChannelWin7 +} + +func (status AppCatalogStatus) valid() bool { + return status == CatalogStatusActive || + status == CatalogStatusDeprecated || + status == CatalogStatusHidden +} + +func (architecture Architecture) valid() bool { + return architecture == Architecture386 || architecture == ArchitectureAMD64 +} + +func (release WindowsRelease) valid() bool { + return release == Windows7SP1 || release == Windows10 || release == Windows11 +} diff --git a/core/catalog/parser_test.go b/core/catalog/parser_test.go new file mode 100644 index 0000000..3f97e79 --- /dev/null +++ b/core/catalog/parser_test.go @@ -0,0 +1,151 @@ +package catalog + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestParserAcceptsValidSignedManifest(t *testing.T) { + verifier, document := signedFixture(t, "manifest-valid-payload.json") + verified, err := verifier.Verify(document) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + + manifest, err := (Parser{ExpectedChannel: ChannelModern}).Parse(verified) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(manifest.Apps) != 1 || manifest.Apps[0].ID != "json-parser" { + t.Fatalf("Apps = %#v", manifest.Apps) + } +} + +func TestParserRejectsWrongChannelAndUnknownFields(t *testing.T) { + verifier, document := signedFixture(t, "manifest-valid-payload.json") + verified, err := verifier.Verify(document) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + _, err = (Parser{ExpectedChannel: ChannelWin7}).Parse(verified) + if !errors.Is(err, ErrChannelMismatch) { + t.Fatalf("Parse() error = %v, want %v", err, ErrChannelMismatch) + } + + var payload map[string]any + if err := json.Unmarshal(readCatalogFixture(t, "manifest-valid-payload.json"), &payload); err != nil { + t.Fatalf("decode fixture: %v", err) + } + payload["unexpected"] = true + mutatedPayload, err := json.Marshal(payload) + if err != nil { + t.Fatalf("encode fixture: %v", err) + } + _, privateKey := catalogTestKey() + mutatedDocument, _ := signCatalogPayload(t, mutatedPayload, privateKey) + verified, err = verifier.Verify(mutatedDocument) + if err != nil { + t.Fatalf("Verify(mutated) error = %v", err) + } + _, err = (Parser{ExpectedChannel: ChannelModern}).Parse(verified) + if !errors.Is(err, ErrInvalidManifest) { + t.Fatalf("Parse(mutated) error = %v, want %v", err, ErrInvalidManifest) + } +} + +func TestParserRejectsDuplicateAppIDAndPackageMismatch(t *testing.T) { + base := validManifestForTest() + base.Apps = append(base.Apps, base.Apps[0]) + _, err := parseSignedManifestForTest(t, base, ChannelModern) + if !errors.Is(err, ErrInvalidManifest) { + t.Fatalf("duplicate Parse() error = %v, want %v", err, ErrInvalidManifest) + } + + base = validManifestForTest() + delete(base.Apps[0].Packages, ArchitectureAMD64) + _, err = parseSignedManifestForTest(t, base, ChannelModern) + if !errors.Is(err, ErrInvalidManifest) { + t.Fatalf("package Parse() error = %v, want %v", err, ErrInvalidManifest) + } +} + +func signedFixture(t *testing.T, name string) (Verifier, []byte) { + t.Helper() + publicKey, privateKey := catalogTestKey() + verifier, err := NewVerifier(publicKey) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + document, _ := signCatalogPayload(t, readCatalogFixture(t, name), privateKey) + return verifier, document +} + +func validManifestForTest() Manifest { + return Manifest{ + SchemaVersion: 1, + Channel: ChannelModern, + GeneratedAt: "2026-07-16T00:00:00Z", + MinBoxVersion: "1.0.0", + Apps: []App{ + { + ID: "json-parser", + Name: "JSON解析工具", + Description: "测试目录项", + Version: "1.2.0", + Channel: ReleaseStable, + Status: CatalogStatusActive, + Category: "开发工具", + Tags: []string{"工具", "JSON"}, + Icon: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + Homepage: "https://example.invalid/json-parser", + Tutorial: "https://example.invalid/json-parser/tutorial", + MinOS: Windows10, + Architectures: []Architecture{ArchitectureAMD64}, + EntryEXE: "JsonParser.exe", + Packages: map[Architecture]Package{ + ArchitectureAMD64: { + URL: "https://download.invalid/json-parser.zip", + Size: 42, + SHA256: "0000000000000000000000000000000000000000000000000000000000000000", + Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + }, + }, + }, + }, + } +} + +func parseSignedManifestForTest( + t *testing.T, + manifest Manifest, + expectedChannel ManifestChannel, +) (Manifest, error) { + t.Helper() + manifest.Signature = "" + payloadValue := map[string]any{} + encodedManifest, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("encode manifest: %v", err) + } + if err := json.Unmarshal(encodedManifest, &payloadValue); err != nil { + t.Fatalf("decode manifest: %v", err) + } + delete(payloadValue, "signature") + payload, err := json.Marshal(payloadValue) + if err != nil { + t.Fatalf("encode payload: %v", err) + } + + publicKey, privateKey := catalogTestKey() + document, _ := signCatalogPayload(t, payload, privateKey) + verifier, err := NewVerifier(publicKey) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + verified, err := verifier.Verify(document) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + return (Parser{ExpectedChannel: expectedChannel}).Parse(verified) +} diff --git a/core/catalog/schema_test.go b/core/catalog/schema_test.go new file mode 100644 index 0000000..35af71e --- /dev/null +++ b/core/catalog/schema_test.go @@ -0,0 +1,29 @@ +package catalog + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestProtocolSchemasAreValidJSONObjects(t *testing.T) { + for _, name := range []string{"manifest.schema.json", "app.schema.json"} { + t.Run(name, func(t *testing.T) { + document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name)) + if err != nil { + t.Fatalf("read schema: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(document, &schema); err != nil { + t.Fatalf("decode schema: %v", err) + } + if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" { + t.Fatalf("$schema = %v", schema["$schema"]) + } + if schema["type"] != "object" { + t.Fatalf("type = %v", schema["type"]) + } + }) + } +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8b37135..1336fc6 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -103,8 +103,10 @@ soft_quay/ - 软件主键是永久稳定的 `id`(小写英文/数字/短横线),不用名称;下架用 `status`,不用名称前缀。 - 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。 -- Phase 1 清单签名原型会拒绝重复字段、尾随 JSON 与非整数数字;签名域为移除顶层 `signature` 后的受限规范 JSON,细节见 [api.md](api.md)。 +- Catalog 正式加载顺序为 HTTPS 获取 → 验签 → 严格字段/Schema/channel 校验 → 缓存替换 → 目标过滤;签名正确但结构或目标通道不匹配的远端内容不能挤掉最后可消费缓存。 +- 清单解析拒绝未知字段、重复字段/软件 ID、尾随 JSON、非整数数字、非 HTTPS URL 与 architectures/packages 映射不一致;签名域为移除顶层 `signature` 后的受限规范 JSON,细节见 [api.md](api.md)。 - channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。 +- `category` 提供稳定单分类,`tags` 用于搜索和多标签展示;hidden 项从远端目录隐藏,deprecated 与不兼容项保留可见原因但不提供安装包操作。 ### 4.2 本地动态数据(JSON + 原子写入) diff --git a/docs/api.md b/docs/api.md index 60f285e..4d07af9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,8 +29,9 @@ "version": "1.2.0", "channel": "stable", "status": "active", + "category": "开发工具", "tags": ["工具", "JSON"], - "icon": "sha256:...", + "icon": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "homepage": "https://example.com", "tutorial": "https://example.com/tutorial", "min_os": "windows-7-sp1", @@ -53,21 +54,27 @@ 行为要求: -- 网络成功:验签通过才替换本地缓存;验签失败**拒绝**,继续用最后一次验证成功的缓存。 +- 网络成功:按“HTTPS 获取 → 验签 → Schema/字段/目标 channel 校验 → 替换缓存”处理;任一步失败都**拒绝**新内容,继续用最后一次验证且客户端可消费的缓存。 - 网络失败:用缓存;清单过期给提示,但保留已安装软件的启动能力。 - 下架:显式 `status: deprecated | hidden`,不用名称前缀。 -- 过滤:按 `min_os` 与 `architectures` 过滤;不兼容软件可见说明但不可下载。 +- 分类:`category` 是单一稳定分类,`tags` 是搜索/多标签展示数据;两者都不得为空。 +- 过滤:先校验 manifest `channel`,再按 `min_os`、`architectures` 和 `packages` 过滤;不兼容软件可见说明但不可下载。 +- 状态:`active` 可安装;`deprecated` 可见但不可新装/更新;`hidden` 不进入目录结果,但本地已安装记录仍由本地状态模块保留。 +- URL:Catalog、package、homepage、tutorial 只接受无用户信息、无 fragment 的绝对 HTTPS URL。 +- 架构:MVP Schema 接受 `386` / `amd64`;一个 app 的 `architectures` 必须与 `packages` 键一一对应。当前两个客户端发布目标仍是 amd64。 +- 系统版本:`min_os` v1 只允许 `windows-7-sp1`、`windows-10`、`windows-11`;更高系统可消费更低最低版本的软件。 +- Schema:客户端协议文件为 `schemas/manifest.schema.json`;标准包元数据为 `schemas/app.schema.json`。运行时还会执行 JSON Schema 难以表达的重复 ID、架构映射和 channel 目标一致性检查。 -### 1.1 Phase 1 签名域原型 +### 1.1 Catalog 签名域 -T-101 验证采用以下签名域,供客户端与后续发布器实现对齐: +客户端采用以下签名域,供发布器实现对齐: 1. 输入必须是单个 UTF-8 JSON object;重复字段、尾随 JSON、浮点/指数数字直接拒绝。 2. 读取顶层 `signature`(标准 Base64 编码的 64 字节 Ed25519 签名),然后从对象中移除该字段。 3. 对剩余值递归规范化:对象键按 Unicode 字符串升序排列;数组保持原顺序;字符串按 JSON 转义;数字仅允许 JSON 整数并保持其合法十进制写法;不保留无意义空白。 4. Ed25519 直接签名/验证上述规范 JSON 字节。 -这是 Phase 1 风险原型结论。T-201 正式接入时必须与 `softbox-catalog` 发布端做跨实现向量测试,再冻结 Schema、密钥 ID/轮换字段和版本兼容策略;在此之前不得另造签名域。 +T-201 已把该签名域接入正式客户端加载链路并用客户端测试向量覆盖。`softbox-catalog` 发布端仍必须补跨实现向量测试;密钥 ID/轮换字段尚未定稿,在单公钥协议升级前不得另造签名域。 ## 2. 标准软件包协议 v1(ZIP) @@ -223,7 +230,7 @@ V1.1:命名管道 `\\.\pipe\softbox.`,盒子发送 `{"command": "prepare ## 待实现时确认 -- 清单签名封装格式(签名域、密钥轮换字段)定稿后同步 `schemas/`。 +- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。 - 错误码完整枚举表。 - 图标资源的分发方式(内嵌哈希 vs 独立 URL)。 - 撤销名单的结构与宽限期时长。 diff --git a/docs/current-state.md b/docs/current-state.md index 0225620..da3dc6d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,36 +13,36 @@ ## 当前快照 - 日期:2026-07-16 -- 阶段:M2 已完成(清单验签、ZIP 安全解压、staging/current/backup 切换与崩溃恢复原型全部验证) +- 阶段:Phase 2 进行中;T-201 Catalog 正式接入已完成,下一步 T-202 本地安装状态识别 - 技术栈:根 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 验签/缓存、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell -- 测试:core 覆盖 Catalog、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub -- 数据:`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵 +- 生产代码: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 攻击矩阵 - 标准启动路径:`./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-201 +- 当前 blocker:无;下一步按路线图落成并领取 T-202 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0 与 Phase 1 的任务均已完成;T-201 待按路线图落成 | +| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201 已完成;T-202 待按路线图落成 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容;已有状态/事件与 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/` | 待建 | 协议 JSON Schema(T-201) | +| `schemas/` | 已建 | `manifest.schema.json` 与 `app.schema.json` | | `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 | ## 任务状态 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`。 - 正在进行:无。 -- 下一个可领取任务:按路线图落成并领取 `T-201 catalog 模块正式接入`。 +- 下一个可领取任务:按路线图落成并领取 `T-202 本地安装状态识别`。 ## 当前可运行内容 diff --git a/docs/tasks/T-201.md b/docs/tasks/T-201.md new file mode 100644 index 0000000..bbcf531 --- /dev/null +++ b/docs/tasks/T-201.md @@ -0,0 +1,66 @@ +--- +id: T-201 +title: Catalog 模块正式接入 +phase: 2 +deps: [T-101] +status: DONE +created: 2026-07-16 +issue: null +context_ref: 9da72ca0d8563fb5dca8a5675461e49dc656b74e +claim_branch: null +work_branch: agent/codex/T-201 +write_paths: + - docs/tasks/T-201.md + - core/catalog/ + - schemas/ + - testdata/catalog/ + - testdata/README.md + - docs/api.md + - docs/04-architecture.md + - docs/current-state.md +--- + +## 问题 / 背景 + +Phase 1 已验证 Catalog 顶层签名与最后有效缓存回退,但仍只有“可信 JSON 字节”,没有正式的 HTTPS 获取、协议字段校验、modern/win7 通道约束、OS/架构过滤和上下架处理。若在结构或通道校验前写缓存,签名正确但客户端无法消费的清单也可能替换最后可用版本。 + +## 方案 + +1. 在 `core/catalog` 建立 manifest/app/package 强类型模型和严格解析校验,拒绝未知字段、重复软件 ID、非法枚举、非 HTTPS 包 URL及不一致的架构映射。 +2. Loader 支持在缓存替换前执行文档 validator;正式 Client 固定按“验签 → Schema/通道校验 → 缓存 → 目标过滤”加载。 +3. 增加有体积上限的 HTTPS Fetcher,并保留 Fetcher/Cache 注入以便无网络测试。 +4. 目标过滤同时处理 manifest channel、`min_os`、`architectures`、package 架构与 `status`:hidden 不展示,deprecated 与 incompatible 可见但不可安装并给出稳定原因码。 +5. 在 `schemas/` 落地 manifest 与 app.json 的 JSON Schema;同步协议与架构文档。 + +## 验收要点 + +- 签名、结构、通道全部通过后才替换缓存;结构非法或通道错误时回退最后验证且可消费的缓存。 +- HTTP 获取只允许 HTTPS、拒绝非 2xx/超限响应。 +- modern/win7、最低 OS、386/amd64 与 package 映射过滤正确。 +- active 可安装;deprecated 可见不可安装;hidden 不进入结果;不兼容项可见并返回稳定原因。 +- `manifest.schema.json` 与 `app.schema.json` 可由标准 JSON 解析器读取,示例字段与 `docs/api.md` 一致。 +- Go 1.20 core vet/test、完整双目标闸门和治理校验通过。 + +## 边界(不改什么) + +- 不实现 installed-app.json、SemVer 比较和本地 12 状态识别(T-202)。 +- 不实现 Gio 列表、详情或图标资源下载(T-203/T-204)。 +- 不冻结密钥轮换字段或图标 URL;发布端跨实现签名向量仍需在独立仓库对齐。 +- 不引入第三方 Schema、HTTP 或 SemVer 依赖。 + +## 协作约束 + +未启用 Gitea;本任务在 `agent/codex/T-201` 分支串行执行。正式 Client 必须保护“最后可消费缓存”,不能把 validator 放到缓存写入之后。 + +## 执行记录 + +- 2026-07-16:建立 manifest/app/package 强类型模型、严格 Parser 与正式 Client;加载顺序固定为“HTTPS 获取 → Ed25519 验签 → 字段/Schema/channel 校验 → 缓存替换 → 目标过滤”。 +- 2026-07-16:Loader 增加缓存写入前 DocumentValidator;测试证明签名正确但 channel/结构不匹配的远端清单不会覆盖最后可消费缓存。 +- 2026-07-16:增加有响应体上限和 HTTPS 重定向约束的 HTTPFetcher;错误状态、非 HTTPS 与超限响应均返回稳定错误。 +- 2026-07-16:实现 modern/win7、Windows 7 SP1/10/11、386/amd64 过滤;active 可安装,deprecated 与 incompatible 可见不可安装并带稳定原因,hidden 不进入目录结果。 +- 2026-07-16:正式协议补充必填 `category`,与 `tags` 分别承担单分类与搜索标签;图标仍仅定义 `sha256:` 内容引用,未越权确定分发 URL。 +- 2026-07-16:新增 `schemas/manifest.schema.json`、`schemas/app.schema.json`,并同步 `docs/api.md`、`docs/04-architecture.md`、`docs/current-state.md` 与公开虚构 testdata。 +- 定向验证通过:`go -C core test -count=1 ./catalog`。 +- Schema 语法验证通过:`python -m json.tool schemas/manifest.schema.json`、`python -m json.tool schemas/app.schema.json`。 +- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。 +- 提交前检查通过:`git diff --check`。 diff --git a/schemas/app.schema.json b/schemas/app.schema.json new file mode 100644 index 0000000..13a938e --- /dev/null +++ b/schemas/app.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://softbox.invalid/schemas/app.schema.json", + "title": "SoftBox Package app.json v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "name", + "vendor", + "version", + "channel", + "min_os", + "architecture", + "entrypoint", + "working_directory", + "product_id", + "supports_trial", + "requires_admin", + "data_policy", + "update_policy" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "vendor": { + "type": "string", + "minLength": 1 + }, + "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-]+)*)?$" + }, + "channel": { + "const": "stable" + }, + "min_os": { + "enum": ["windows-7-sp1", "windows-10", "windows-11"] + }, + "architecture": { + "enum": ["386", "amd64"] + }, + "entrypoint": { + "$ref": "#/$defs/safeRelativePath" + }, + "working_directory": { + "oneOf": [ + { + "const": "." + }, + { + "$ref": "#/$defs/safeRelativePath" + } + ] + }, + "product_id": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "supports_trial": { + "type": "boolean" + }, + "requires_admin": { + "type": "boolean" + }, + "data_policy": { + "const": "local-app-data" + }, + "update_policy": { + "const": "managed-by-softbox" + } + }, + "$defs": { + "safeRelativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$" + } + } +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json new file mode 100644 index 0000000..63ae343 --- /dev/null +++ b/schemas/manifest.schema.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://softbox.invalid/schemas/manifest.schema.json", + "title": "SoftBox Catalog Manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "channel", + "generated_at", + "min_box_version", + "apps", + "signature" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "channel": { + "enum": ["modern", "win7"] + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "min_box_version": { + "$ref": "#/$defs/semver" + }, + "apps": { + "type": "array", + "items": { + "$ref": "#/$defs/app" + } + }, + "signature": { + "$ref": "#/$defs/ed25519Signature" + } + }, + "$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-]+)*)?$" + }, + "ed25519Signature": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]{86}==$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9A-Fa-f]{64}$" + }, + "httpsUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "package": { + "type": "object", + "additionalProperties": false, + "required": ["url", "size", "sha256", "signature"], + "properties": { + "url": { + "$ref": "#/$defs/httpsUrl" + }, + "size": { + "type": "integer", + "minimum": 1 + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "signature": { + "$ref": "#/$defs/ed25519Signature" + } + } + }, + "app": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "version", + "channel", + "status", + "category", + "tags", + "min_os", + "architectures", + "entry_exe", + "requires_admin", + "packages" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "version": { + "$ref": "#/$defs/semver" + }, + "channel": { + "const": "stable" + }, + "status": { + "enum": ["active", "deprecated", "hidden"] + }, + "category": { + "type": "string", + "minLength": 1 + }, + "tags": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "icon": { + "type": "string", + "pattern": "^sha256:[0-9A-Fa-f]{64}$" + }, + "homepage": { + "$ref": "#/$defs/httpsUrl" + }, + "tutorial": { + "$ref": "#/$defs/httpsUrl" + }, + "min_os": { + "enum": ["windows-7-sp1", "windows-10", "windows-11"] + }, + "architectures": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["386", "amd64"] + } + }, + "entry_exe": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$" + }, + "requires_admin": { + "type": "boolean" + }, + "packages": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "386": { + "$ref": "#/$defs/package" + }, + "amd64": { + "$ref": "#/$defs/package" + } + } + } + } + } + } +} diff --git a/testdata/README.md b/testdata/README.md index 2974bbf..ceaac02 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -5,3 +5,4 @@ - 不放生产私钥、真实注册码、真实机器标识或真实下载地址。 - 测试若需要签名,使用测试代码中明确标注的专用测试密钥。 - 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。 +- `catalog/manifest-valid-payload.json` 同时作为 manifest v1 强类型解析与目标过滤的公开虚构样例;包哈希与签名只保证格式合法,不对应真实下载物。 diff --git a/testdata/catalog/manifest-tampered-payload.json b/testdata/catalog/manifest-tampered-payload.json index 81ac5be..eabfbe8 100644 --- a/testdata/catalog/manifest-tampered-payload.json +++ b/testdata/catalog/manifest-tampered-payload.json @@ -11,7 +11,9 @@ "version": "9.9.9", "channel": "stable", "status": "active", + "category": "开发工具", "tags": ["工具", "JSON"], + "icon": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "min_os": "windows-10", "architectures": ["amd64"], "entry_exe": "JsonParser.exe", @@ -20,8 +22,8 @@ "amd64": { "url": "https://download.invalid/forged.zip", "size": 42, - "sha256": "forged-sha256-placeholder", - "signature": "forged-package-signature-placeholder" + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" } } } diff --git a/testdata/catalog/manifest-valid-payload.json b/testdata/catalog/manifest-valid-payload.json index de507b0..42400a5 100644 --- a/testdata/catalog/manifest-valid-payload.json +++ b/testdata/catalog/manifest-valid-payload.json @@ -11,7 +11,11 @@ "version": "1.2.0", "channel": "stable", "status": "active", + "category": "开发工具", "tags": ["工具", "JSON"], + "icon": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "homepage": "https://example.invalid/json-parser", + "tutorial": "https://example.invalid/json-parser/tutorial", "min_os": "windows-10", "architectures": ["amd64"], "entry_exe": "JsonParser.exe", @@ -20,8 +24,8 @@ "amd64": { "url": "https://download.invalid/json-parser.zip", "size": 42, - "sha256": "test-sha256-placeholder", - "signature": "test-package-signature-placeholder" + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" } } }