Add app details and icon cache (T-204)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled

This commit is contained in:
ila
2026-07-16 17:22:37 +08:00
parent db8d93e843
commit a52acbf926
13 changed files with 1224 additions and 58 deletions
+13
View File
@@ -23,6 +23,9 @@ type CatalogListItem struct {
Version string
Category string
Tags []string
IconRef string
Homepage string
Tutorial string
Status domain.AppStatus
Installed bool
Installable bool
@@ -141,6 +144,16 @@ func (model *CatalogListModel) SelectedID() string {
return model.selectedID
}
// SelectedItem returns the selected source item without changing filters.
func (model *CatalogListModel) SelectedItem() (CatalogListItem, bool) {
for _, item := range model.items {
if item.ID == model.selectedID {
return item, true
}
}
return CatalogListItem{}, false
}
// Valid reports whether view is supported by the MVP list.
func (view CatalogView) Valid() bool {
return view == CatalogViewAll ||
+4
View File
@@ -64,6 +64,10 @@ func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
if model.SelectedID() != "app-a" {
t.Fatalf("SelectedID = %q", model.SelectedID())
}
selected, ok := model.SelectedItem()
if !ok || selected.ID != "app-a" {
t.Fatalf("SelectedItem() = %#v, %t", selected, ok)
}
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
if model.SelectedID() != "" {
+317
View File
@@ -0,0 +1,317 @@
package catalog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
const (
DefaultMaxIconBytes int64 = 2 << 20
DefaultMaxIconDimension = 2048
)
var (
ErrIconReferenceInvalid = errors.New("icon reference is invalid")
ErrIconDPIInvalid = errors.New("icon DPI is invalid")
ErrIconHashMismatch = errors.New("icon SHA-256 does not match reference")
ErrIconTooLarge = errors.New("icon exceeds resource limits")
ErrIconImageInvalid = errors.New("icon image is invalid")
ErrIconCacheUnsafe = errors.New("icon cache layout is unsafe")
ErrNoValidIcon = errors.New("no valid icon available")
)
// IconRequest identifies one content-addressed icon at one UI DPI.
type IconRequest struct {
Reference string
DPI int
}
// IconFetcher obtains icon bytes outside Gio Layout.
type IconFetcher interface {
FetchIcon(context.Context, IconRequest) ([]byte, error)
}
// IconFetchFunc adapts a function to IconFetcher.
type IconFetchFunc func(context.Context, IconRequest) ([]byte, error)
func (function IconFetchFunc) FetchIcon(
ctx context.Context,
request IconRequest,
) ([]byte, error) {
return function(ctx, request)
}
// IconSource identifies the cache tier that returned bytes.
type IconSource string
const (
IconSourceMemory IconSource = "memory"
IconSourceDisk IconSource = "disk"
IconSourceRemote IconSource = "remote"
)
// IconResult contains verified bytes and a non-fatal disk-store warning.
type IconResult struct {
Bytes []byte
Source IconSource
Warning error
}
// IconLoadError preserves disk and refresh failures.
type IconLoadError struct {
Disk error
Fetch error
}
func (loadError *IconLoadError) Error() string {
return fmt.Sprintf(
"%s: disk=%v; fetch=%v",
ErrNoValidIcon,
loadError.Disk,
loadError.Fetch,
)
}
func (loadError *IconLoadError) Unwrap() []error {
return []error{ErrNoValidIcon, loadError.Disk, loadError.Fetch}
}
// IconCache is a content-verified memory + disk cache keyed by digest and DPI.
type IconCache struct {
root string
fetcher IconFetcher
maxBytes int64
maxDimension int
mu sync.Mutex
memory map[string][]byte
}
// NewIconCache creates a cache with safe default resource limits.
func NewIconCache(root string, fetcher IconFetcher) *IconCache {
return &IconCache{
root: root,
fetcher: fetcher,
maxBytes: DefaultMaxIconBytes,
maxDimension: DefaultMaxIconDimension,
memory: make(map[string][]byte),
}
}
// Load resolves memory, verified disk, then verified remote bytes.
func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResult, error) {
cache.mu.Lock()
defer cache.mu.Unlock()
digest, key, err := cacheKey(request)
if err != nil {
return IconResult{}, err
}
if document, exists := cache.memory[key]; exists {
return IconResult{
Bytes: append([]byte(nil), document...),
Source: IconSourceMemory,
}, nil
}
filePath, pathErr := cache.filePath(digest, request.DPI)
if pathErr != nil {
return IconResult{}, pathErr
}
document, diskErr := cache.loadDisk(filePath, digest)
if diskErr == nil {
cache.memory[key] = append([]byte(nil), document...)
return IconResult{
Bytes: append([]byte(nil), document...),
Source: IconSourceDisk,
}, nil
}
if !os.IsNotExist(diskErr) {
if errors.Is(diskErr, ErrIconCacheUnsafe) {
return IconResult{}, diskErr
}
if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
return IconResult{}, fmt.Errorf(
"%w: remove invalid cache entry: %v",
ErrIconCacheUnsafe,
removeErr,
)
}
}
if cache.fetcher == nil {
return IconResult{}, &IconLoadError{
Disk: diskErr,
Fetch: errors.New("icon fetcher is not configured"),
}
}
document, fetchErr := cache.fetcher.FetchIcon(ctx, request)
if fetchErr != nil {
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr}
}
if err := cache.validate(document, digest); err != nil {
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: err}
}
storeErr := cache.storeDisk(filePath, document)
cache.memory[key] = append([]byte(nil), document...)
return IconResult{
Bytes: append([]byte(nil), document...),
Source: IconSourceRemote,
Warning: storeErr,
}, nil
}
// DecodeIcon decodes already verified bytes outside Layout for ApplyIcon.
func DecodeIcon(document []byte) (image.Image, error) {
decoded, _, err := image.Decode(bytes.NewReader(document))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrIconImageInvalid, err)
}
return decoded, nil
}
func (cache *IconCache) validate(document []byte, digest string) error {
maxBytes := cache.maxBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxIconBytes
}
if int64(len(document)) > maxBytes {
return fmt.Errorf(
"%w: bytes=%d limit=%d",
ErrIconTooLarge,
len(document),
maxBytes,
)
}
actual := sha256.Sum256(document)
if hex.EncodeToString(actual[:]) != digest {
return ErrIconHashMismatch
}
config, _, err := image.DecodeConfig(bytes.NewReader(document))
if err != nil {
return fmt.Errorf("%w: decode config: %v", ErrIconImageInvalid, err)
}
maxDimension := cache.maxDimension
if maxDimension <= 0 {
maxDimension = DefaultMaxIconDimension
}
if config.Width <= 0 ||
config.Height <= 0 ||
config.Width > maxDimension ||
config.Height > maxDimension {
return fmt.Errorf(
"%w: dimensions=%dx%d limit=%d",
ErrIconTooLarge,
config.Width,
config.Height,
maxDimension,
)
}
if _, _, err := image.Decode(bytes.NewReader(document)); err != nil {
return fmt.Errorf("%w: decode: %v", ErrIconImageInvalid, err)
}
return nil
}
func (cache *IconCache) filePath(digest string, dpi int) (string, error) {
if cache.root == "" {
return "", fmt.Errorf("%w: empty root", ErrIconCacheUnsafe)
}
absoluteRoot, err := filepath.Abs(cache.root)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrIconCacheUnsafe, err)
}
return filepath.Join(
absoluteRoot,
digest+"-"+strconv.Itoa(dpi)+".icon",
), nil
}
func (cache *IconCache) loadDisk(filePath, digest 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: cache entry is not a regular file", ErrIconCacheUnsafe)
}
document, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("read icon cache: %w", err)
}
if err := cache.validate(document, digest); err != nil {
return nil, err
}
return document, nil
}
func (cache *IconCache) storeDisk(filePath string, document []byte) error {
directory := filepath.Dir(filePath)
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("create icon cache directory: %w", err)
}
info, err := os.Lstat(directory)
if err != nil {
return fmt.Errorf("inspect icon cache directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("%w: root is not a real directory", ErrIconCacheUnsafe)
}
temporary, err := os.CreateTemp(directory, ".icon-*.tmp")
if err != nil {
return fmt.Errorf("create icon cache temp file: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("protect icon cache temp file: %w", err)
}
if _, err := temporary.Write(document); err != nil {
temporary.Close()
return fmt.Errorf("write icon cache temp file: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("sync icon cache temp file: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close icon cache temp file: %w", err)
}
if err := os.Rename(temporaryPath, filePath); err != nil {
return fmt.Errorf("activate icon cache entry: %w", err)
}
return nil
}
func cacheKey(request IconRequest) (digest string, key string, err error) {
const prefix = "sha256:"
if !strings.HasPrefix(request.Reference, prefix) {
return "", "", ErrIconReferenceInvalid
}
digest = strings.ToLower(strings.TrimPrefix(request.Reference, prefix))
decoded, decodeErr := hex.DecodeString(digest)
if decodeErr != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 {
return "", "", ErrIconReferenceInvalid
}
if request.DPI < 48 || request.DPI > 768 {
return "", "", ErrIconDPIInvalid
}
return digest, digest + "@" + strconv.Itoa(request.DPI), nil
}
+248
View File
@@ -0,0 +1,248 @@
package catalog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
)
func TestIconCacheUsesMemoryAndOfflineDisk(t *testing.T) {
document := testPNG(t, 16, 16)
request := iconRequest(document, 96)
fetchCalls := 0
root := t.TempDir()
cache := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
fetchCalls++
return document, nil
}))
result, err := cache.Load(context.Background(), request)
if err != nil {
t.Fatalf("Load(remote) error = %v", err)
}
if result.Source != IconSourceRemote || fetchCalls != 1 {
t.Fatalf("remote result = %#v, fetchCalls=%d", result, fetchCalls)
}
result, err = cache.Load(context.Background(), request)
if err != nil {
t.Fatalf("Load(memory) error = %v", err)
}
if result.Source != IconSourceMemory || fetchCalls != 1 {
t.Fatalf("memory result = %#v, fetchCalls=%d", result, fetchCalls)
}
offline := errors.New("offline")
restarted := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
return nil, offline
}))
result, err = restarted.Load(context.Background(), request)
if err != nil {
t.Fatalf("Load(disk) error = %v", err)
}
if result.Source != IconSourceDisk {
t.Fatalf("disk source = %q, want %q", result.Source, IconSourceDisk)
}
}
func TestIconCacheSeparatesDPIKeys(t *testing.T) {
document := testPNG(t, 16, 16)
fetchCalls := 0
root := t.TempDir()
cache := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
fetchCalls++
return document, nil
}))
for _, dpi := range []int{96, 144} {
if _, err := cache.Load(context.Background(), iconRequest(document, dpi)); err != nil {
t.Fatalf("Load(%d DPI) error = %v", dpi, err)
}
}
if fetchCalls != 2 {
t.Fatalf("fetchCalls = %d, want 2", fetchCalls)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("ReadDir() error = %v", err)
}
if len(entries) != 2 {
t.Fatalf("disk entries = %d, want 2", len(entries))
}
}
func TestIconCacheRejectsUntrustedImages(t *testing.T) {
validDocument := testPNG(t, 16, 16)
tests := []struct {
name string
request IconRequest
document []byte
wantErr error
configure func(*IconCache)
}{
{
name: "hash mismatch",
request: iconRequest([]byte("different"), 96),
document: validDocument,
wantErr: ErrIconHashMismatch,
},
{
name: "invalid image",
request: iconRequest([]byte("not an image"), 96),
document: []byte("not an image"),
wantErr: ErrIconImageInvalid,
},
{
name: "byte limit",
request: iconRequest(validDocument, 96),
document: validDocument,
wantErr: ErrIconTooLarge,
configure: func(cache *IconCache) {
cache.maxBytes = int64(len(validDocument) - 1)
},
},
{
name: "dimension limit",
request: iconRequest(validDocument, 96),
document: validDocument,
wantErr: ErrIconTooLarge,
configure: func(cache *IconCache) {
cache.maxDimension = 8
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
cache := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
return test.document, nil
}))
if test.configure != nil {
test.configure(cache)
}
_, err := cache.Load(context.Background(), test.request)
if !errors.Is(err, test.wantErr) {
t.Fatalf("Load() error = %v, want %v", err, test.wantErr)
}
entries, readErr := os.ReadDir(root)
if readErr != nil {
t.Fatalf("ReadDir() error = %v", readErr)
}
if len(entries) != 0 {
t.Fatalf("invalid icon wrote %d disk entries", len(entries))
}
})
}
}
func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) {
document := testPNG(t, 16, 16)
request := iconRequest(document, 120)
root := t.TempDir()
online := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
return document, nil
}))
if _, err := online.Load(context.Background(), request); err != nil {
t.Fatalf("Load(seed) error = %v", err)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 1 {
t.Fatalf("ReadDir() = %v, %v", entries, err)
}
filePath := filepath.Join(root, entries[0].Name())
if err := os.WriteFile(filePath, []byte("corrupt"), 0o600); err != nil {
t.Fatalf("WriteFile(corrupt) error = %v", err)
}
repairs := 0
repairing := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
repairs++
return document, nil
}))
result, err := repairing.Load(context.Background(), request)
if err != nil {
t.Fatalf("Load(repair) error = %v", err)
}
if result.Source != IconSourceRemote || repairs != 1 {
t.Fatalf("repair result = %#v, repairs=%d", result, repairs)
}
if err := os.WriteFile(filePath, []byte("corrupt again"), 0o600); err != nil {
t.Fatalf("WriteFile(corrupt again) error = %v", err)
}
offline := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) ([]byte, error) {
return nil, errors.New("offline")
}))
_, err = offline.Load(context.Background(), request)
if !errors.Is(err, ErrNoValidIcon) {
t.Fatalf("Load(offline corrupt) error = %v, want %v", err, ErrNoValidIcon)
}
}
func TestDecodeIcon(t *testing.T) {
document := testPNG(t, 8, 8)
decoded, err := DecodeIcon(document)
if err != nil {
t.Fatalf("DecodeIcon() error = %v", err)
}
if decoded.Bounds().Dx() != 8 || decoded.Bounds().Dy() != 8 {
t.Fatalf("Bounds = %v", decoded.Bounds())
}
}
func iconRequest(document []byte, dpi int) IconRequest {
digest := sha256.Sum256(document)
return IconRequest{
Reference: "sha256:" + hex.EncodeToString(digest[:]),
DPI: dpi,
}
}
func testPNG(t *testing.T, width, height int) []byte {
t.Helper()
source := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
source.SetNRGBA(x, y, color.NRGBA{
R: uint8(x),
G: uint8(y),
B: 120,
A: 255,
})
}
}
var buffer bytes.Buffer
if err := png.Encode(&buffer, source); err != nil {
t.Fatalf("png.Encode() error = %v", err)
}
return buffer.Bytes()
}