Author SHA1 Message Date
ila 1b7f72e658 Add adapter interaction contracts (T-608)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
2026-07-17 10:41:40 +08:00
ila 0705948d74 Define adapter interaction contract task (T-608) 2026-07-17 10:34:14 +08:00
ila 0945fe93dc Route icon results through UI events (T-607) 2026-07-17 10:21:44 +08:00
ila fba672381e Define icon UI event delivery task (T-607) 2026-07-17 09:54:47 +08:00
ila f1cc7308db Harden icon cache concurrency (T-606) 2026-07-17 09:38:25 +08:00
ila 8873a5261d Define icon cache hardening task (T-606) 2026-07-17 09:10:20 +08:00
ila 6fd19d0f43 Harden Windows package paths (T-605) 2026-07-16 23:56:19 +08:00
ila f7a803d944 Define Windows path hardening task (T-605) 2026-07-16 23:36:39 +08:00
ila 7efcab5dfe Isolate modern and Win7 workspaces (T-604) 2026-07-16 21:27:52 +08:00
ila e4a9295cbf Define workspace isolation task (T-604) 2026-07-16 21:15:40 +08:00
ilaandClaude Fable 5 a036ce9a8e Add Phase 0 skeleton review with cross-check ruling
Record the full-stack review of the Phase 0 skeleton (T-001~T-004),
Codex's second-pass rebuttal, and the cross-checked final ruling:
confirmed facts (with evidence), accept/correct notes, and a
finalized action order. P1 (root go.work Win7 version bleed) is the
only structural fix flagged for near-term handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:09:25 +08:00
ila 90dc09d13d Use single-agent workflow 2026-07-16 20:42:32 +08:00
ila 8dad40f934 Implement resumable download queue (T-301) 2026-07-16 19:49:06 +08:00
ila 2d302b731a Define multi-agent download workflow (T-301) 2026-07-16 18:48:15 +08:00
ila a52acbf926 Add app details and icon cache (T-204)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
2026-07-16 17:22:37 +08:00
ila db8d93e843 Build virtualized software list (T-203) 2026-07-16 17:10:42 +08:00
ila 819b1cdf88 Recognize local app states (T-202) 2026-07-16 17:00:06 +08:00
ila 2e21c9f327 Integrate validated catalog loading (T-201) 2026-07-16 16:52:46 +08:00
ila 9da72caa01 Prototype atomic install recovery (T-103) 2026-07-16 16:32:58 +08:00
ila 0ffeff63e1 Prototype secure ZIP extraction (T-102) 2026-07-16 16:22:45 +08:00
ila 0d6ed05d7e Prototype signed catalog fallback (T-101) 2026-07-16 16:13:18 +08:00
ila 45c242ecec Add Phase 0 build verification gate (T-004) 2026-07-16 15:51:34 +08:00
ila 6f920eb457 Add Gio app shells and platform stubs (T-003) 2026-07-16 15:43:03 +08:00
ila 0e20dd76b2 Add domain states and event runtime (T-002) 2026-07-16 15:31:41 +08:00
ila cf9fc01c68 Initialize Phase 0 monorepo skeleton (T-001) 2026-07-16 15:28:13 +08:00
155 changed files with 21110 additions and 121 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Phase 0 build gate
on:
push:
pull_request:
permissions: read-all
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Go 1.25
uses: actions/setup-go@v5
with:
go-version: "1.25.0"
cache: false
- name: Run Phase 0 verification
run: bash scripts/verify_phase0.sh
+9
View File
@@ -45,6 +45,15 @@
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。 - 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。 - 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
## Agent 执行模式
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
## 验证 ## 验证
```bash ```bash
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"errors"
"log"
"os"
"gioui.org/app"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/app-modern/platform/windows"
softboxgio "softbox.local/app-modern/ui/gio"
"softbox.local/core"
"softbox.local/core/application"
)
const applicationEventCapacity = 32
func main() {
go func() {
if err := run(); err != nil {
log.Printf("%s stopped: %v", core.ProductName, err)
}
os.Exit(0)
}()
app.Main()
}
func run() error {
platform := windows.New()
window := new(app.Window)
window.Option(
app.Title(core.ProductName),
app.Size(unit.Dp(1080), unit.Dp(720)),
)
theme := softboxgio.NewTheme()
shell := softboxgio.NewAppShell(string(platform.Edition()))
runtime := application.NewRuntime(applicationEventCapacity)
relay, err := application.NewEventRelay(applicationEventCapacity)
if err != nil {
return err
}
eventContext, cancelEvents := context.WithCancel(context.Background())
pumpDone := make(chan error, 1)
go func() {
pumpDone <- application.PumpEvents(
eventContext,
runtime.Events(),
relay,
window.Invalidate,
)
}()
defer func() {
cancelEvents()
runtime.Close()
relay.Close()
if pumpErr := <-pumpDone; pumpErr != nil &&
!errors.Is(pumpErr, context.Canceled) &&
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
log.Printf("application event pump stopped: %v", pumpErr)
}
}()
var operations op.Ops
for {
switch event := window.Event().(type) {
case app.DestroyEvent:
return event.Err
case app.FrameEvent:
if err := relay.Drain(shell.ApplyEvent); err != nil {
log.Printf("apply application event: %v", err)
}
context := app.NewContext(&operations, event)
shell.Layout(context, theme)
event.Frame(context.Ops)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
module softbox.local/app-modern
go 1.25.0
require (
gioui.org v0.10.1
softbox.local/core v0.0.0
)
+3
View File
@@ -0,0 +1,3 @@
// Package windows provides modern Windows platform adapters and non-Windows
// stubs for package-level tests.
package windows
+17
View File
@@ -0,0 +1,17 @@
package windows
// Edition identifies the application build channel shown by the UI.
type Edition string
const EditionModern Edition = "Modern"
// Platform is the minimal boundary for target-specific capabilities.
type Platform interface {
OS() string
Edition() Edition
}
// New returns the platform implementation selected by build tags.
func New() Platform {
return newPlatform()
}
@@ -0,0 +1,19 @@
//go:build !windows
package windows
import "runtime"
type platformStub struct{}
func newPlatform() Platform {
return platformStub{}
}
func (platformStub) OS() string {
return runtime.GOOS
}
func (platformStub) Edition() Edition {
return EditionModern
}
@@ -0,0 +1,13 @@
package windows
import "testing"
func TestPlatformStubContract(t *testing.T) {
platform := New()
if platform.OS() == "" {
t.Fatal("OS() should not be empty")
}
if platform.Edition() != EditionModern {
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionModern)
}
}
@@ -0,0 +1,17 @@
//go:build windows
package windows
type platform struct{}
func newPlatform() Platform {
return platform{}
}
func (platform) OS() string {
return "windows"
}
func (platform) Edition() Edition {
return EditionModern
}
+270
View File
@@ -0,0 +1,270 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var (
adapterContractViewport = image.Pt(1080, 720)
adapterContractCompactViewport = image.Pt(1080, 420)
)
const adapterContractEdition = "Modern"
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
shell.search.SetText("APP-TWO")
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Query(); got != "app-two" {
t.Fatalf("query after editor update = %q, want app-two", got)
}
visible := shell.model.VisibleItems()
if len(visible) != 1 || visible[0].ID != "app-two" {
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
}
shell.search.SetText("")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Category(); got != "图像" {
t.Fatalf("category after click = %q, want 图像", got)
}
shell.viewUpdates.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewUpdates {
t.Fatalf("view after updates click = %q", got)
}
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewInstalled {
t.Fatalf("view after installed click = %q", got)
}
shell.viewAll.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewAll {
t.Fatalf("view after all click = %q", got)
}
shell.search.SetText("missing-app")
nodes := adapterContractLayout(shell, adapterContractViewport)
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
!adapterContractHasSemantic(nodes, "显示全部软件") {
t.Fatal("filtered empty state did not expose its recovery action")
}
shell.resetFilters.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.search.Text() != "" || shell.model.Query() != "" ||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
t.Fatalf(
"reset state = editor %q, query %q, category %q, view %q",
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
)
}
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
}
}
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
items := adapterContractLargeCatalog(80)
shell := NewAppShell(adapterContractEdition, items...)
targetID := "app-037"
targetControl := shell.rows[targetID]
reordered := append([]application.CatalogListItem(nil), items...)
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
reordered[left], reordered[right] = reordered[right], reordered[left]
}
shell.SetItems(reordered)
if shell.rows[targetID] != targetControl {
t.Fatal("row control was recreated after catalog reorder")
}
shell.search.SetText("app-")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
shell.appList.ScrollTo(12)
adapterContractLayout(shell, adapterContractViewport)
targetControl.open.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != targetID {
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
}
if !shell.detailRendered {
t.Fatal("row click selected the model but did not render detail")
}
queryBefore := shell.model.Query()
categoryBefore := shell.model.Category()
viewBefore := shell.model.View()
positionBefore := shell.appList.Position
shell.closeDetail.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != "" {
t.Fatalf("selected ID after close = %q, want empty", got)
}
if shell.detailRendered {
t.Fatal("detail remained rendered after close click")
}
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
shell.model.View() != viewBefore {
t.Fatal("closing detail changed the active list filters")
}
positionAfter := shell.appList.Position
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
t.Fatalf(
"list position after close = first %d offset %d, want first %d offset %d",
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
)
}
}
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
items := adapterContractLargeCatalog(500)
shell := NewAppShell(adapterContractEdition, items...)
retainedRow := shell.rows["app-001"]
retainedCategory := shell.categoryControls["图像"]
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
}
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
}
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
t.Fatal("filtering recreated stable app or category controls")
}
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
if shell.rows["app-001"] != retainedRow {
t.Fatal("retained app lost its row control after snapshot update")
}
if shell.categoryControls["图像"] != retainedCategory {
t.Fatal("retained category lost its control after snapshot update")
}
if _, exists := shell.rows["app-000"]; exists {
t.Fatal("removed app retained its row control")
}
if _, exists := shell.categoryControls["工具"]; exists {
t.Fatal("removed category retained its control")
}
}
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
emptyShell := NewAppShell(adapterContractEdition)
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
t.Fatal("empty catalog did not render the catalog-unavailable state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
}
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
filteredShell.search.SetText("missing-app")
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
t.Fatal("filtered catalog did not render the no-matches state")
}
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
t.Fatal("filtered catalog did not render its recovery action")
}
filteredShell.resetFilters.Click()
adapterContractLayout(filteredShell, adapterContractViewport)
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
t.Fatal("filter recovery did not clear editor and model query")
}
if len(filteredShell.model.VisibleItems()) == 0 {
t.Fatal("filter recovery did not restore catalog rows")
}
}
func adapterContractItems() []application.CatalogListItem {
return []application.CatalogListItem{
{
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
Status: domain.StatusNotInstalled, Installable: true,
},
{
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
Status: domain.StatusInstalled, Installed: true, Installable: true,
},
{
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
},
}
}
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
items := make([]application.CatalogListItem, count)
for index := range items {
category := "工具"
if index%2 == 1 {
category = "图像"
}
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("App %03d", index),
Version: "1.0.0",
Category: category,
Status: domain.StatusInstalled,
Installed: true,
Installable: true,
}
}
return items
}
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
var operations op.Ops
var router input.Router
context := layout.Context{
Ops: &operations,
Source: router.Source(),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
shell.Layout(context, NewTheme())
router.Frame(&operations)
return router.AppendSemantics(nil)
}
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
for _, node := range nodes {
if node.Desc.Label == want || node.Desc.Description == want ||
adapterContractHasSemantic(node.Children, want) {
return true
}
}
return false
}
func adapterContractIDs(items []application.CatalogListItem) []string {
ids := make([]string, len(items))
for index, item := range items {
ids[index] = item.ID
}
return ids
}
+5
View File
@@ -0,0 +1,5 @@
// Package gio contains the modern Gio UI adapter.
//
// Layout code is rendering-only: it must not read files, access the network,
// calculate hashes, or directly mutate background application state.
package gio
+102
View File
@@ -0,0 +1,102 @@
package gio
import (
"errors"
"fmt"
"softbox.local/core/application"
)
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
// ExpectIcon records the newest request identity on the UI goroutine.
func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error {
validated, err := application.NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return err
}
currentReference, exists := shell.iconReferences[validated.AppID]
if !exists || currentReference != validated.Reference {
return fmt.Errorf(
"%w: app=%q reference=%q",
ErrIconRequestStale,
validated.AppID,
validated.Reference,
)
}
if applied, exists := shell.iconApplied[validated.AppID]; !exists || !sameIconResource(applied, validated) {
delete(shell.icons, validated.AppID)
delete(shell.iconApplied, validated.AppID)
}
shell.iconRequests[validated.AppID] = validated
delete(shell.iconFailures, validated.AppID)
return nil
}
// CancelIconRequest invalidates the matching pending request on the UI goroutine.
func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
pending, exists := shell.iconRequests[appID]
if !exists || pending.RequestID != requestID {
return false
}
delete(shell.iconRequests, appID)
return true
}
// ApplyEvent validates and applies an application event on the UI goroutine.
func (shell *AppShell) ApplyEvent(event application.Event) error {
iconEvent, handled, err := application.ParseIconEvent(event)
if err != nil || !handled {
return err
}
identity := iconEvent.Identity
pending, exists := shell.iconRequests[identity.AppID]
if !exists || pending != identity {
return nil
}
if shell.iconReferences[identity.AppID] != identity.Reference {
return nil
}
delete(shell.iconRequests, identity.AppID)
switch iconEvent.Type {
case application.EventIconReady:
shell.ApplyIcon(identity.AppID, iconEvent.Image)
shell.iconApplied[identity.AppID] = identity
case application.EventIconFailed:
applied, hasApplied := shell.iconApplied[identity.AppID]
if !hasApplied || !sameIconResource(applied, identity) {
shell.ApplyIcon(identity.AppID, nil)
}
shell.iconFailures[identity.AppID] = iconEvent.ErrorCode
}
return nil
}
// IconFailure exposes the last failure for diagnostics without raw network data.
func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) {
failure, exists := shell.iconFailures[appID]
return failure, exists
}
func canonicalIconReference(reference string) string {
canonical, err := application.NormalizeIconReference(reference)
if err != nil {
return reference
}
return canonical
}
func sameIconResource(
left application.IconEventIdentity,
right application.IconEventIdentity,
) bool {
return left.AppID == right.AppID &&
left.Reference == right.Reference &&
left.DPI == right.DPI
}
+264
View File
@@ -0,0 +1,264 @@
package gio
import (
"context"
"errors"
"image"
"strings"
"testing"
"time"
"softbox.local/core/application"
)
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
reference := testIconReference("11")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
ready, err := application.NewIconReadyEvent(
identity,
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
)
if err != nil {
t.Fatal(err)
}
relay, err := application.NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
submitted := make(chan error, 1)
go func() {
submitted <- relay.Submit(context.Background(), ready)
}()
if err := waitIconSubmit(submitted); err != nil {
t.Fatalf("Submit() error = %v", err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("background relay changed shell before UI drain")
}
if err := relay.Drain(shell.ApplyEvent); err != nil {
t.Fatalf("Drain() error = %v", err)
}
icon, exists := shell.icons["app-one"]
if !exists || icon.Size() != image.Pt(24, 24) {
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
}
}
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
reference := testIconReference("22")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("stale request inserted an icon")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
t.Fatalf("latest icon size = %v", got)
}
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
if err := shell.ExpectIcon(retryIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("same-resource retry discarded an already valid icon")
}
failed, err := application.NewIconFailedEvent(
retryIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(failed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("matching failure discarded an already valid icon")
}
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
}
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
if err := shell.ExpectIcon(dpiIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI request retained an unmatching image")
}
dpiFailed, err := application.NewIconFailedEvent(
dpiIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(dpiFailed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI failure restored an unmatching image")
}
}
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
oldReference := testIconReference("33")
newReference := testIconReference("44")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: oldReference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
shell.SetItems([]application.CatalogListItem{{
ID: "app-one",
Name: "One",
IconRef: newReference,
}})
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("IconRef change retained the previous image")
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("old IconRef result was reinserted")
}
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the latest request")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("canceled result was applied")
}
shell.SetItems(nil)
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
t.Fatalf("ExpectIcon(removed app) error = %v", err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app was reinserted by a late result")
}
}
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
shell := NewAppShell("Test")
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); err != nil {
t.Fatalf("ApplyEvent(non-icon) error = %v", err)
}
err := shell.ApplyEvent(application.Event{
Type: application.EventIconReady,
RequestID: "request",
AppID: "app-one",
Payload: "wrong",
})
if !errors.Is(err, application.ErrInvalidIconEvent) {
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
}
if len(shell.icons) != 0 {
t.Fatal("invalid payload polluted icon state")
}
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
func testIconIdentity(
t *testing.T,
requestID string,
appID string,
reference string,
dpi int,
) application.IconEventIdentity {
t.Helper()
identity, err := application.NewIconEventIdentity(
requestID,
appID,
reference,
dpi,
)
if err != nil {
t.Fatal(err)
}
return identity
}
func waitIconSubmit(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for icon relay")
}
}
+959
View File
@@ -0,0 +1,959 @@
package gio
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var shellColors = struct {
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
border color.NRGBA
success color.NRGBA
warning color.NRGBA
destructive color.NRGBA
}{
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
}
type rowControls struct {
open widget.Clickable
}
// AppShell is the modern software catalog window.
type AppShell struct {
edition string
model *application.CatalogListModel
search widget.Editor
appList layout.List
categoryList layout.List
viewAll widget.Clickable
viewInstalled widget.Clickable
viewUpdates widget.Clickable
resetFilters widget.Clickable
closeDetail widget.Clickable
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
icons map[string]paint.ImageOp
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]application.IconFailureCode
lastRendered int
detailRendered bool
}
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
func NewAppShell(
edition string,
items ...application.CatalogListItem,
) *AppShell {
shell := &AppShell{
edition: edition,
model: application.NewCatalogListModel(nil),
appList: layout.List{Axis: layout.Vertical},
categoryList: layout.List{Axis: layout.Horizontal},
categoryControls: make(map[string]*widget.Clickable),
rows: make(map[string]*rowControls),
icons: make(map[string]paint.ImageOp),
iconReferences: make(map[string]string),
iconRequests: make(map[string]application.IconEventIdentity),
iconApplied: make(map[string]application.IconEventIdentity),
iconFailures: make(map[string]application.IconFailureCode),
}
shell.search.SingleLine = true
shell.SetItems(items)
return shell
}
// ApplyIcon stores a decoded image for future Layout calls.
// It is UI-goroutine-only; background workers must publish application events.
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
delete(shell.iconApplied, appID)
delete(shell.iconFailures, appID)
if icon == nil {
delete(shell.icons, appID)
return
}
shell.icons[appID] = paint.NewImageOp(icon)
}
// SetItems applies a prepared, IO-free catalog/status snapshot.
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
shell.model.SetItems(items)
nextRows := make(map[string]*rowControls, len(items))
nextIcons := make(map[string]paint.ImageOp, len(items))
nextReferences := make(map[string]string, len(items))
nextRequests := make(map[string]application.IconEventIdentity, len(items))
nextApplied := make(map[string]application.IconEventIdentity, len(items))
nextFailures := make(map[string]application.IconFailureCode, len(items))
for _, item := range items {
controls := shell.rows[item.ID]
if controls == nil {
controls = new(rowControls)
}
nextRows[item.ID] = controls
reference := canonicalIconReference(item.IconRef)
nextReferences[item.ID] = reference
if previous, exists := shell.iconReferences[item.ID]; exists && previous == reference {
if icon, exists := shell.icons[item.ID]; exists {
nextIcons[item.ID] = icon
}
if request, exists := shell.iconRequests[item.ID]; exists && request.Reference == reference {
nextRequests[item.ID] = request
}
if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference {
nextApplied[item.ID] = applied
}
if failure, exists := shell.iconFailures[item.ID]; exists {
nextFailures[item.ID] = failure
}
}
}
shell.rows = nextRows
shell.icons = nextIcons
shell.iconReferences = nextReferences
shell.iconRequests = nextRequests
shell.iconApplied = nextApplied
shell.iconFailures = nextFailures
nextCategories := make(map[string]*widget.Clickable)
for _, category := range append([]string{""}, shell.model.Categories()...) {
control := shell.categoryControls[category]
if control == nil {
control = new(widget.Clickable)
}
nextCategories[category] = control
}
shell.categoryControls = nextCategories
}
// NewTheme creates the accessible semantic palette shared by the modern shell.
func NewTheme() *material.Theme {
theme := material.NewTheme()
theme.Palette = material.Palette{
Bg: shellColors.background,
Fg: shellColors.foreground,
ContrastBg: shellColors.primary,
ContrastFg: shellColors.onPrimary,
}
theme.FingerSize = unit.Dp(44)
return theme
}
// Layout drains input first and performs no disk, network or hash IO.
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
shell.drainInput(gtx)
shell.lastRendered = 0
shell.detailRendered = false
paint.Fill(gtx.Ops, shellColors.background)
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutHeader(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return shell.layoutContent(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFooter(gtx, theme)
}),
)
})
}
func (shell *AppShell) drainInput(gtx layout.Context) {
for {
if _, ok := shell.search.Update(gtx); !ok {
break
}
}
shell.model.SetQuery(shell.search.Text())
for shell.viewAll.Clicked(gtx) {
shell.model.SetView(application.CatalogViewAll)
}
for shell.viewInstalled.Clicked(gtx) {
shell.model.SetView(application.CatalogViewInstalled)
}
for shell.viewUpdates.Clicked(gtx) {
shell.model.SetView(application.CatalogViewUpdates)
}
for category, control := range shell.categoryControls {
for control.Clicked(gtx) {
shell.model.SetCategory(category)
}
}
for appID, controls := range shell.rows {
for controls.open.Clicked(gtx) {
shell.model.Select(appID)
}
}
for shell.resetFilters.Clicked(gtx) {
shell.search.SetText("")
shell.model.ResetFilters()
}
for shell.closeDetail.Clicked(gtx) {
shell.model.Select("")
}
}
func (shell *AppShell) layoutHeader(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H4(theme, "SoftBox").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, "发现、安装并更新可信软件")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(48)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := shellColors.border
if gtx.Focused(&shell.search) {
border = shellColors.primary
}
return outlinedPanel(
gtx,
border,
shellColors.surface,
unit.Dp(8),
layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(14), Right: unit.Dp(14),
},
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
editor := material.Editor(theme, &shell.search, "搜索名称、软件 ID 或标签")
editor.TextSize = unit.Sp(15)
return editor.Layout(gtx)
},
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutCategories(gtx, theme)
}),
)
}
func (shell *AppShell) layoutCategories(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
categories := append([]string{""}, shell.model.Categories()...)
height := gtx.Dp(unit.Dp(44))
gtx.Constraints.Min.Y = height
gtx.Constraints.Max.Y = height
return shell.categoryList.Layout(gtx, len(categories), func(
gtx layout.Context,
index int,
) layout.Dimensions {
category := categories[index]
label := category
if label == "" {
label = "全部分类"
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
shell.categoryControls[category],
label,
shell.model.Category() == category,
)
})
})
}
func (shell *AppShell) layoutContent(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(168))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return panel(
gtx,
shellColors.muted,
unit.Dp(10),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "软件视图")
label.Color = shellColors.secondary
return layout.Inset{
Left: unit.Dp(8), Bottom: unit.Dp(8),
}.Layout(gtx, label.Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewAll,
"全部软件",
application.CatalogViewAll,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewInstalled,
"已安装",
application.CatalogViewInstalled,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewUpdates,
"可更新",
application.CatalogViewUpdates,
)
}),
)
},
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
selected, hasSelection := shell.model.SelectedItem()
return layout.Flex{}.Layout(
gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return panel(
gtx,
shellColors.surface,
unit.Dp(10),
layout.UniformInset(unit.Dp(16)),
func(gtx layout.Context) layout.Dimensions {
return shell.layoutCatalog(gtx, theme)
},
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasSelection {
return layout.Dimensions{}
}
return layout.Spacer{Width: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasSelection {
return layout.Dimensions{}
}
width := gtx.Dp(unit.Dp(320))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return shell.layoutDetail(gtx, theme, selected)
}),
)
}),
)
}
func (shell *AppShell) layoutCatalog(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
visible := shell.model.VisibleItems()
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(
theme,
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(visible) == 0 {
return shell.layoutEmptyState(gtx, theme)
}
return shell.appList.Layout(gtx, len(visible), func(
gtx layout.Context,
index int,
) layout.Dimensions {
shell.lastRendered++
return shell.layoutAppRow(gtx, theme, visible[index])
})
}),
)
}
func (shell *AppShell) layoutAppRow(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
controls := shell.rows[item.ID]
if controls == nil {
return layout.Dimensions{}
}
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(88))
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
semantic.DescriptionOp(fmt.Sprintf(
"%s,版本 %s,状态 %s",
item.Name,
item.Version,
statusLabel(item.Status),
)).Add(gtx.Ops)
background := shellColors.muted
if controls.open.Hovered() || gtx.Focused(&controls.open) {
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
}
if shell.model.SelectedID() == item.ID {
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
}
return panel(
gtx,
background,
unit.Dp(8),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(48),
unit.Dp(8),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H6(theme, item.Name).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(
theme,
fmt.Sprintf(
"%s · %s · %s",
item.ID,
item.Version,
item.Category,
),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body1(theme, statusLabel(item.Status))
label.Color = statusColor(item.Status)
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, actionLabel(item))
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
)
},
)
})
})
}
func (shell *AppShell) layoutAppIcon(
gtx layout.Context,
theme *material.Theme,
appID string,
name string,
iconSize unit.Dp,
radius unit.Dp,
) layout.Dimensions {
size := gtx.Dp(iconSize)
gtx.Constraints.Min = image.Pt(size, size)
gtx.Constraints.Max = gtx.Constraints.Min
if icon, exists := shell.icons[appID]; exists {
return panel(
gtx,
shellColors.surface,
radius,
layout.UniformInset(unit.Dp(2)),
func(gtx layout.Context) layout.Dimensions {
return widget.Image{
Src: icon,
Fit: widget.Contain,
Position: layout.Center,
}.Layout(gtx)
},
)
}
letter := "S"
for _, character := range name {
letter = string(character)
break
}
return panel(
gtx,
shellColors.primary,
radius,
layout.UniformInset(unit.Dp(0)),
func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.H6(theme, letter)
label.Color = shellColors.onPrimary
return label.Layout(gtx)
})
},
)
}
func (shell *AppShell) layoutDetail(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
shell.detailRendered = true
return panel(
gtx,
shellColors.muted,
unit.Dp(10),
layout.UniformInset(unit.Dp(16)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, "软件详情").Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.closeDetail,
"关闭",
false,
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(72),
unit.Dp(12),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, material.H6(theme, item.Name).Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(
theme,
fmt.Sprintf("%s · %s", item.ID, item.Version),
)
label.Color = shellColors.secondary
return layout.Center.Layout(gtx, label.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "状态", statusLabel(item.Status))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"标签",
fallbackText(strings.Join(item.Tags, " · "), "无"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"简介",
fallbackText(item.Description, "暂无简介"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Reason == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Tutorial == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "教程", item.Tutorial)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Homepage == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "主页", item.Homepage)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, actionLabel(item)+";实际操作将在后续用例接入")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
},
)
}
func (shell *AppShell) layoutEmptyState(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
title := "没有匹配的软件"
body := "尝试清除搜索词、分类或视图筛选。"
showReset := shell.model.TotalCount() > 0
if shell.model.TotalCount() == 0 {
title = "软件目录尚未加载"
body = "联网刷新或存在已验证缓存后,软件会显示在这里。"
}
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, body)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showReset {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.resetFilters,
"显示全部软件",
true,
)
})
}),
)
})
}
func (shell *AppShell) layoutViewButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
view application.CatalogView,
) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
clickable,
label,
shell.model.View() == view,
)
}
func (shell *AppShell) layoutFilterButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
active bool,
) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, clickable, label)
button.CornerRadius = unit.Dp(8)
button.Inset = layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(14), Right: unit.Dp(14),
}
if active {
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
} else {
button.Background = shellColors.muted
button.Color = shellColors.foreground
}
return button.Layout(gtx)
}
func (shell *AppShell) layoutFooter(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "目录状态:等待已验证 Catalog")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, shell.edition+" · Windows 10/11 x64")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}
func panel(
gtx layout.Context,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return layout.Background{}.Layout(
gtx,
func(gtx layout.Context) layout.Dimensions {
paint.FillShape(
gtx.Ops,
background,
clip.UniformRRect(
image.Rectangle{Max: gtx.Constraints.Min},
gtx.Dp(radius),
).Op(gtx.Ops),
)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, content)
},
)
}
func outlinedPanel(
gtx layout.Context,
border color.NRGBA,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return panel(
gtx,
border,
radius,
layout.UniformInset(unit.Dp(1)),
func(gtx layout.Context) layout.Dimensions {
return panel(gtx, background, radius-unit.Dp(1), inset, content)
},
)
}
func viewTitle(view application.CatalogView) string {
switch view {
case application.CatalogViewInstalled:
return "已安装软件"
case application.CatalogViewUpdates:
return "可更新软件"
default:
return "全部软件"
}
}
func statusLabel(status domain.AppStatus) string {
switch status {
case domain.StatusQueued:
return "排队中"
case domain.StatusDownloading:
return "下载中"
case domain.StatusVerifying:
return "校验中"
case domain.StatusExtracting:
return "解压中"
case domain.StatusInstalling:
return "安装中"
case domain.StatusInstalled:
return "已安装"
case domain.StatusUpdateAvailable:
return "可更新"
case domain.StatusRunning:
return "运行中"
case domain.StatusFailed:
return "失败"
case domain.StatusRollbackPending:
return "待恢复"
case domain.StatusIncompatible:
return "不兼容"
default:
return "未安装"
}
}
func statusColor(status domain.AppStatus) color.NRGBA {
switch status {
case domain.StatusFailed, domain.StatusRollbackPending:
return shellColors.destructive
case domain.StatusUpdateAvailable:
return shellColors.warning
case domain.StatusInstalled, domain.StatusRunning:
return shellColors.success
case domain.StatusIncompatible:
return shellColors.secondary
default:
return shellColors.primary
}
}
func actionLabel(item application.CatalogListItem) string {
if item.Reason != "" || item.Status == domain.StatusIncompatible {
return "查看不可用原因"
}
switch item.Status {
case domain.StatusInstalled:
return "查看或启动"
case domain.StatusUpdateAvailable:
return "查看更新"
case domain.StatusRunning:
return "查看运行状态"
case domain.StatusQueued,
domain.StatusDownloading,
domain.StatusVerifying,
domain.StatusExtracting,
domain.StatusInstalling:
return "查看任务"
case domain.StatusFailed, domain.StatusRollbackPending:
return "查看恢复选项"
default:
if item.Installable {
return "查看并安装"
}
return "查看详情"
}
}
func detailField(
gtx layout.Context,
theme *material.Theme,
labelText string,
value string,
) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, labelText)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(material.Body2(theme, value).Layout),
)
})
}
func fallbackText(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func reasonLabel(reason string) string {
switch reason {
case "deprecated":
return "软件已停止发布,不能新装或更新"
case "minimum_os":
return "当前 Windows 版本低于最低要求"
case "architecture":
return "没有适用于当前系统架构的软件包"
default:
return reason
}
}
+133
View File
@@ -0,0 +1,133 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
func TestAppShellFillsWindow(t *testing.T) {
var operations op.Ops
size := image.Pt(1080, 720)
context := layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
dimensions := NewAppShell("Modern").Layout(context, NewTheme())
if dimensions.Size != size {
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
}
}
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
items := make([]application.CatalogListItem, 500)
for index := range items {
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("软件 %03d", index),
Version: "1.0.0",
Category: "工具",
Tags: []string{"工具"},
Status: domain.StatusNotInstalled,
Installable: true,
}
}
shell := NewAppShell("Modern", items...)
context := testContext(image.Pt(1080, 420))
shell.Layout(context, NewTheme())
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf(
"lastRendered = %d, want visible subset of %d",
shell.lastRendered,
len(items),
)
}
}
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
items := []application.CatalogListItem{
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
}
shell := NewAppShell("Modern", items...)
original := shell.rows["app-two"]
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
shell.model.SetCategory("图像")
shell.Layout(testContext(image.Pt(1080, 720)), NewTheme())
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after filtering")
}
shell.SetItems([]application.CatalogListItem{
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
})
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after snapshot update")
}
if _, exists := shell.rows["app-one"]; exists {
t.Fatal("removed app retained row controls")
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app retained prepared icon")
}
icon, exists := shell.icons["app-two"]
if !exists {
t.Fatal("retained app lost its prepared icon")
}
if icon.Size() != image.Pt(24, 24) {
t.Fatalf("retained app icon size = %v", icon.Size())
}
}
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
item := application.CatalogListItem{
ID: "json-parser",
Name: "JSON解析工具",
Description: "格式化并检查 JSON",
Version: "1.2.0",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Homepage: "https://example.invalid/json-parser",
Tutorial: "https://example.invalid/json-parser/tutorial",
Status: domain.StatusInstalled,
Installed: true,
}
shell := NewAppShell("Modern", item)
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
shell.ApplyIcon(item.ID, icon)
shell.model.Select(item.ID)
shell.Layout(testContext(image.Pt(1280, 800)), NewTheme())
if !shell.detailRendered {
t.Fatal("selected app detail was not rendered")
}
if _, exists := shell.icons[item.ID]; !exists {
t.Fatal("ApplyIcon did not retain the prepared image operation")
}
shell.ApplyIcon(item.ID, nil)
if _, exists := shell.icons[item.ID]; exists {
t.Fatal("ApplyIcon(nil) did not remove the image")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"errors"
"log"
"os"
"gioui.org/app"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/app-win7/platform/windows"
softboxgio "softbox.local/app-win7/ui/gio"
"softbox.local/core"
"softbox.local/core/application"
)
const applicationEventCapacity = 32
func main() {
go func() {
if err := run(); err != nil {
log.Printf("%s Legacy stopped: %v", core.ProductName, err)
}
os.Exit(0)
}()
app.Main()
}
func run() error {
platform := windows.New()
window := new(app.Window)
window.Option(
app.Title(core.ProductName+" Legacy"),
app.Size(unit.Dp(1024), unit.Dp(680)),
)
theme := softboxgio.NewTheme()
shell := softboxgio.NewAppShell(string(platform.Edition()))
runtime := application.NewRuntime(applicationEventCapacity)
relay, err := application.NewEventRelay(applicationEventCapacity)
if err != nil {
return err
}
eventContext, cancelEvents := context.WithCancel(context.Background())
pumpDone := make(chan error, 1)
go func() {
pumpDone <- application.PumpEvents(
eventContext,
runtime.Events(),
relay,
window.Invalidate,
)
}()
defer func() {
cancelEvents()
runtime.Close()
relay.Close()
if pumpErr := <-pumpDone; pumpErr != nil &&
!errors.Is(pumpErr, context.Canceled) &&
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
log.Printf("application event pump stopped: %v", pumpErr)
}
}()
var operations op.Ops
for {
switch event := window.Event().(type) {
case app.DestroyEvent:
return event.Err
case app.FrameEvent:
if err := relay.Drain(shell.ApplyEvent); err != nil {
log.Printf("apply application event: %v", err)
}
context := app.NewContext(&operations, event)
shell.Layout(context, theme)
event.Frame(context.Ops)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
module softbox.local/app-win7
go 1.20
require (
gioui.org v0.6.0
softbox.local/core v0.0.0
)
+8
View File
@@ -0,0 +1,8 @@
go 1.20
use (
.
../core
)
replace softbox.local/core v0.0.0 => ../core
+10
View File
@@ -0,0 +1,10 @@
gioui.org v0.6.0 h1:ZSXO/AbpFZJ2L9NU69uFQfDI3BKIH+YEJElrn0B+aZI=
gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+3
View File
@@ -0,0 +1,3 @@
// Package windows provides Win7-compatible platform adapters and non-Windows
// stubs for package-level tests.
package windows
+17
View File
@@ -0,0 +1,17 @@
package windows
// Edition identifies the application build channel shown by the UI.
type Edition string
const EditionLegacy Edition = "Legacy"
// Platform is the minimal boundary for target-specific capabilities.
type Platform interface {
OS() string
Edition() Edition
}
// New returns the platform implementation selected by build tags.
func New() Platform {
return newPlatform()
}
@@ -0,0 +1,19 @@
//go:build !windows
package windows
import "runtime"
type platformStub struct{}
func newPlatform() Platform {
return platformStub{}
}
func (platformStub) OS() string {
return runtime.GOOS
}
func (platformStub) Edition() Edition {
return EditionLegacy
}
@@ -0,0 +1,13 @@
package windows
import "testing"
func TestPlatformStubContract(t *testing.T) {
platform := New()
if platform.OS() == "" {
t.Fatal("OS() should not be empty")
}
if platform.Edition() != EditionLegacy {
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionLegacy)
}
}
@@ -0,0 +1,17 @@
//go:build windows
package windows
type platform struct{}
func newPlatform() Platform {
return platform{}
}
func (platform) OS() string {
return "windows"
}
func (platform) Edition() Edition {
return EditionLegacy
}
+270
View File
@@ -0,0 +1,270 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var (
adapterContractViewport = image.Pt(1024, 680)
adapterContractCompactViewport = image.Pt(1024, 380)
)
const adapterContractEdition = "Legacy"
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
shell.search.SetText("APP-TWO")
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Query(); got != "app-two" {
t.Fatalf("query after editor update = %q, want app-two", got)
}
visible := shell.model.VisibleItems()
if len(visible) != 1 || visible[0].ID != "app-two" {
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
}
shell.search.SetText("")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Category(); got != "图像" {
t.Fatalf("category after click = %q, want 图像", got)
}
shell.viewUpdates.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewUpdates {
t.Fatalf("view after updates click = %q", got)
}
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewInstalled {
t.Fatalf("view after installed click = %q", got)
}
shell.viewAll.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewAll {
t.Fatalf("view after all click = %q", got)
}
shell.search.SetText("missing-app")
nodes := adapterContractLayout(shell, adapterContractViewport)
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
!adapterContractHasSemantic(nodes, "显示全部软件") {
t.Fatal("filtered empty state did not expose its recovery action")
}
shell.resetFilters.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.search.Text() != "" || shell.model.Query() != "" ||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
t.Fatalf(
"reset state = editor %q, query %q, category %q, view %q",
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
)
}
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
}
}
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
items := adapterContractLargeCatalog(80)
shell := NewAppShell(adapterContractEdition, items...)
targetID := "app-037"
targetControl := shell.rows[targetID]
reordered := append([]application.CatalogListItem(nil), items...)
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
reordered[left], reordered[right] = reordered[right], reordered[left]
}
shell.SetItems(reordered)
if shell.rows[targetID] != targetControl {
t.Fatal("row control was recreated after catalog reorder")
}
shell.search.SetText("app-")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
shell.appList.ScrollTo(12)
adapterContractLayout(shell, adapterContractViewport)
targetControl.open.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != targetID {
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
}
if !shell.detailRendered {
t.Fatal("row click selected the model but did not render detail")
}
queryBefore := shell.model.Query()
categoryBefore := shell.model.Category()
viewBefore := shell.model.View()
positionBefore := shell.appList.Position
shell.closeDetail.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != "" {
t.Fatalf("selected ID after close = %q, want empty", got)
}
if shell.detailRendered {
t.Fatal("detail remained rendered after close click")
}
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
shell.model.View() != viewBefore {
t.Fatal("closing detail changed the active list filters")
}
positionAfter := shell.appList.Position
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
t.Fatalf(
"list position after close = first %d offset %d, want first %d offset %d",
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
)
}
}
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
items := adapterContractLargeCatalog(500)
shell := NewAppShell(adapterContractEdition, items...)
retainedRow := shell.rows["app-001"]
retainedCategory := shell.categoryControls["图像"]
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
}
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
}
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
t.Fatal("filtering recreated stable app or category controls")
}
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
if shell.rows["app-001"] != retainedRow {
t.Fatal("retained app lost its row control after snapshot update")
}
if shell.categoryControls["图像"] != retainedCategory {
t.Fatal("retained category lost its control after snapshot update")
}
if _, exists := shell.rows["app-000"]; exists {
t.Fatal("removed app retained its row control")
}
if _, exists := shell.categoryControls["工具"]; exists {
t.Fatal("removed category retained its control")
}
}
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
emptyShell := NewAppShell(adapterContractEdition)
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
t.Fatal("empty catalog did not render the catalog-unavailable state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
}
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
filteredShell.search.SetText("missing-app")
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
t.Fatal("filtered catalog did not render the no-matches state")
}
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
t.Fatal("filtered catalog did not render its recovery action")
}
filteredShell.resetFilters.Click()
adapterContractLayout(filteredShell, adapterContractViewport)
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
t.Fatal("filter recovery did not clear editor and model query")
}
if len(filteredShell.model.VisibleItems()) == 0 {
t.Fatal("filter recovery did not restore catalog rows")
}
}
func adapterContractItems() []application.CatalogListItem {
return []application.CatalogListItem{
{
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
Status: domain.StatusNotInstalled, Installable: true,
},
{
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
Status: domain.StatusInstalled, Installed: true, Installable: true,
},
{
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
},
}
}
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
items := make([]application.CatalogListItem, count)
for index := range items {
category := "工具"
if index%2 == 1 {
category = "图像"
}
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("App %03d", index),
Version: "1.0.0",
Category: category,
Status: domain.StatusInstalled,
Installed: true,
Installable: true,
}
}
return items
}
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
var operations op.Ops
var router input.Router
context := layout.Context{
Ops: &operations,
Source: router.Source(),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
shell.Layout(context, NewTheme())
router.Frame(&operations)
return router.AppendSemantics(nil)
}
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
for _, node := range nodes {
if node.Desc.Label == want || node.Desc.Description == want ||
adapterContractHasSemantic(node.Children, want) {
return true
}
}
return false
}
func adapterContractIDs(items []application.CatalogListItem) []string {
ids := make([]string, len(items))
for index, item := range items {
ids[index] = item.ID
}
return ids
}
+5
View File
@@ -0,0 +1,5 @@
// Package gio contains the Win7-compatible Gio UI adapter.
//
// Layout code is rendering-only: it must not read files, access the network,
// calculate hashes, or directly mutate background application state.
package gio
+102
View File
@@ -0,0 +1,102 @@
package gio
import (
"errors"
"fmt"
"softbox.local/core/application"
)
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
// ExpectIcon records the newest request identity on the UI goroutine.
func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error {
validated, err := application.NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return err
}
currentReference, exists := shell.iconReferences[validated.AppID]
if !exists || currentReference != validated.Reference {
return fmt.Errorf(
"%w: app=%q reference=%q",
ErrIconRequestStale,
validated.AppID,
validated.Reference,
)
}
if applied, exists := shell.iconApplied[validated.AppID]; !exists || !sameIconResource(applied, validated) {
delete(shell.icons, validated.AppID)
delete(shell.iconApplied, validated.AppID)
}
shell.iconRequests[validated.AppID] = validated
delete(shell.iconFailures, validated.AppID)
return nil
}
// CancelIconRequest invalidates the matching pending request on the UI goroutine.
func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
pending, exists := shell.iconRequests[appID]
if !exists || pending.RequestID != requestID {
return false
}
delete(shell.iconRequests, appID)
return true
}
// ApplyEvent validates and applies an application event on the UI goroutine.
func (shell *AppShell) ApplyEvent(event application.Event) error {
iconEvent, handled, err := application.ParseIconEvent(event)
if err != nil || !handled {
return err
}
identity := iconEvent.Identity
pending, exists := shell.iconRequests[identity.AppID]
if !exists || pending != identity {
return nil
}
if shell.iconReferences[identity.AppID] != identity.Reference {
return nil
}
delete(shell.iconRequests, identity.AppID)
switch iconEvent.Type {
case application.EventIconReady:
shell.ApplyIcon(identity.AppID, iconEvent.Image)
shell.iconApplied[identity.AppID] = identity
case application.EventIconFailed:
applied, hasApplied := shell.iconApplied[identity.AppID]
if !hasApplied || !sameIconResource(applied, identity) {
shell.ApplyIcon(identity.AppID, nil)
}
shell.iconFailures[identity.AppID] = iconEvent.ErrorCode
}
return nil
}
// IconFailure exposes the last failure for diagnostics without raw network data.
func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) {
failure, exists := shell.iconFailures[appID]
return failure, exists
}
func canonicalIconReference(reference string) string {
canonical, err := application.NormalizeIconReference(reference)
if err != nil {
return reference
}
return canonical
}
func sameIconResource(
left application.IconEventIdentity,
right application.IconEventIdentity,
) bool {
return left.AppID == right.AppID &&
left.Reference == right.Reference &&
left.DPI == right.DPI
}
+264
View File
@@ -0,0 +1,264 @@
package gio
import (
"context"
"errors"
"image"
"strings"
"testing"
"time"
"softbox.local/core/application"
)
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
reference := testIconReference("11")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
ready, err := application.NewIconReadyEvent(
identity,
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
)
if err != nil {
t.Fatal(err)
}
relay, err := application.NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
submitted := make(chan error, 1)
go func() {
submitted <- relay.Submit(context.Background(), ready)
}()
if err := waitIconSubmit(submitted); err != nil {
t.Fatalf("Submit() error = %v", err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("background relay changed shell before UI drain")
}
if err := relay.Drain(shell.ApplyEvent); err != nil {
t.Fatalf("Drain() error = %v", err)
}
icon, exists := shell.icons["app-one"]
if !exists || icon.Size() != image.Pt(24, 24) {
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
}
}
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
reference := testIconReference("22")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("stale request inserted an icon")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
t.Fatalf("latest icon size = %v", got)
}
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
if err := shell.ExpectIcon(retryIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("same-resource retry discarded an already valid icon")
}
failed, err := application.NewIconFailedEvent(
retryIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(failed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("matching failure discarded an already valid icon")
}
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
}
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
if err := shell.ExpectIcon(dpiIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI request retained an unmatching image")
}
dpiFailed, err := application.NewIconFailedEvent(
dpiIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(dpiFailed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI failure restored an unmatching image")
}
}
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
oldReference := testIconReference("33")
newReference := testIconReference("44")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: oldReference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
shell.SetItems([]application.CatalogListItem{{
ID: "app-one",
Name: "One",
IconRef: newReference,
}})
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("IconRef change retained the previous image")
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("old IconRef result was reinserted")
}
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the latest request")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("canceled result was applied")
}
shell.SetItems(nil)
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
t.Fatalf("ExpectIcon(removed app) error = %v", err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app was reinserted by a late result")
}
}
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
shell := NewAppShell("Test")
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); err != nil {
t.Fatalf("ApplyEvent(non-icon) error = %v", err)
}
err := shell.ApplyEvent(application.Event{
Type: application.EventIconReady,
RequestID: "request",
AppID: "app-one",
Payload: "wrong",
})
if !errors.Is(err, application.ErrInvalidIconEvent) {
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
}
if len(shell.icons) != 0 {
t.Fatal("invalid payload polluted icon state")
}
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
func testIconIdentity(
t *testing.T,
requestID string,
appID string,
reference string,
dpi int,
) application.IconEventIdentity {
t.Helper()
identity, err := application.NewIconEventIdentity(
requestID,
appID,
reference,
dpi,
)
if err != nil {
t.Fatal(err)
}
return identity
}
func waitIconSubmit(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for icon relay")
}
}
+880
View File
@@ -0,0 +1,880 @@
package gio
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var shellColors = struct {
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
border color.NRGBA
success color.NRGBA
warning color.NRGBA
destructive color.NRGBA
}{
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
}
type rowControls struct {
open widget.Clickable
}
// AppShell is the Legacy software catalog window.
type AppShell struct {
edition string
model *application.CatalogListModel
search widget.Editor
appList layout.List
categoryList layout.List
viewAll widget.Clickable
viewInstalled widget.Clickable
viewUpdates widget.Clickable
resetFilters widget.Clickable
closeDetail widget.Clickable
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
icons map[string]paint.ImageOp
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]application.IconFailureCode
lastRendered int
detailRendered bool
}
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
func NewAppShell(
edition string,
items ...application.CatalogListItem,
) *AppShell {
shell := &AppShell{
edition: edition,
model: application.NewCatalogListModel(nil),
appList: layout.List{Axis: layout.Vertical},
categoryList: layout.List{Axis: layout.Horizontal},
categoryControls: make(map[string]*widget.Clickable),
rows: make(map[string]*rowControls),
icons: make(map[string]paint.ImageOp),
iconReferences: make(map[string]string),
iconRequests: make(map[string]application.IconEventIdentity),
iconApplied: make(map[string]application.IconEventIdentity),
iconFailures: make(map[string]application.IconFailureCode),
}
shell.search.SingleLine = true
shell.SetItems(items)
return shell
}
// ApplyIcon stores a decoded image for future Layout calls.
// It is UI-goroutine-only; background workers must publish application events.
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
delete(shell.iconApplied, appID)
delete(shell.iconFailures, appID)
if icon == nil {
delete(shell.icons, appID)
return
}
shell.icons[appID] = paint.NewImageOp(icon)
}
// SetItems applies a prepared, IO-free catalog/status snapshot.
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
shell.model.SetItems(items)
nextRows := make(map[string]*rowControls, len(items))
nextIcons := make(map[string]paint.ImageOp, len(items))
nextReferences := make(map[string]string, len(items))
nextRequests := make(map[string]application.IconEventIdentity, len(items))
nextApplied := make(map[string]application.IconEventIdentity, len(items))
nextFailures := make(map[string]application.IconFailureCode, len(items))
for _, item := range items {
controls := shell.rows[item.ID]
if controls == nil {
controls = new(rowControls)
}
nextRows[item.ID] = controls
reference := canonicalIconReference(item.IconRef)
nextReferences[item.ID] = reference
if previous, exists := shell.iconReferences[item.ID]; exists && previous == reference {
if icon, exists := shell.icons[item.ID]; exists {
nextIcons[item.ID] = icon
}
if request, exists := shell.iconRequests[item.ID]; exists && request.Reference == reference {
nextRequests[item.ID] = request
}
if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference {
nextApplied[item.ID] = applied
}
if failure, exists := shell.iconFailures[item.ID]; exists {
nextFailures[item.ID] = failure
}
}
}
shell.rows = nextRows
shell.icons = nextIcons
shell.iconReferences = nextReferences
shell.iconRequests = nextRequests
shell.iconApplied = nextApplied
shell.iconFailures = nextFailures
nextCategories := make(map[string]*widget.Clickable)
for _, category := range append([]string{""}, shell.model.Categories()...) {
control := shell.categoryControls[category]
if control == nil {
control = new(widget.Clickable)
}
nextCategories[category] = control
}
shell.categoryControls = nextCategories
}
// NewTheme creates the accessible palette shared by the Legacy shell.
func NewTheme() *material.Theme {
theme := material.NewTheme()
theme.Palette = material.Palette{
Bg: shellColors.background,
Fg: shellColors.foreground,
ContrastBg: shellColors.primary,
ContrastFg: shellColors.onPrimary,
}
theme.FingerSize = unit.Dp(44)
return theme
}
// Layout drains input first and performs no disk, network or hash IO.
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
shell.drainInput(gtx)
shell.lastRendered = 0
shell.detailRendered = false
paint.Fill(gtx.Ops, shellColors.background)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutHeader(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return shell.layoutContent(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
shell.edition+" · Legacy · Windows 7 SP1 x64",
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
})
}
func (shell *AppShell) drainInput(gtx layout.Context) {
for {
if _, ok := shell.search.Update(gtx); !ok {
break
}
}
shell.model.SetQuery(shell.search.Text())
for shell.viewAll.Clicked(gtx) {
shell.model.SetView(application.CatalogViewAll)
}
for shell.viewInstalled.Clicked(gtx) {
shell.model.SetView(application.CatalogViewInstalled)
}
for shell.viewUpdates.Clicked(gtx) {
shell.model.SetView(application.CatalogViewUpdates)
}
for category, control := range shell.categoryControls {
for control.Clicked(gtx) {
shell.model.SetCategory(category)
}
}
for appID, controls := range shell.rows {
for controls.open.Clicked(gtx) {
shell.model.Select(appID)
}
}
for shell.resetFilters.Clicked(gtx) {
shell.search.SetText("")
shell.model.ResetFilters()
}
for shell.closeDetail.Clicked(gtx) {
shell.model.Select("")
}
}
func (shell *AppShell) layoutHeader(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H5(theme, "SoftBox Legacy").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "兼容 Windows 7 SP1 的可信软件目录")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := shellColors.border
if gtx.Focused(&shell.search) {
border = shellColors.primary
}
return outlinedPanel(
gtx,
border,
shellColors.surface,
unit.Dp(6),
layout.Inset{
Top: unit.Dp(9), Bottom: unit.Dp(9),
Left: unit.Dp(12), Right: unit.Dp(12),
},
func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(theme, &shell.search, "搜索名称、ID 或标签")
editor.TextSize = unit.Sp(14)
return editor.Layout(gtx)
},
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
categories := append([]string{""}, shell.model.Categories()...)
height := gtx.Dp(unit.Dp(44))
gtx.Constraints.Min.Y = height
gtx.Constraints.Max.Y = height
return shell.categoryList.Layout(gtx, len(categories), func(
gtx layout.Context,
index int,
) layout.Dimensions {
category := categories[index]
label := category
if label == "" {
label = "全部分类"
}
return layout.Inset{Right: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
shell.categoryControls[category],
label,
shell.model.Category() == category,
)
})
})
}),
)
}
func (shell *AppShell) layoutContent(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(152))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return panel(
gtx,
shellColors.muted,
unit.Dp(6),
layout.UniformInset(unit.Dp(10)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewAll,
"全部软件",
application.CatalogViewAll,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewInstalled,
"已安装",
application.CatalogViewInstalled,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewUpdates,
"可更新",
application.CatalogViewUpdates,
)
}),
)
},
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
selected, hasSelection := shell.model.SelectedItem()
return layout.Flex{}.Layout(
gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return panel(
gtx,
shellColors.surface,
unit.Dp(6),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
visible := shell.model.VisibleItems()
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
fmt.Sprintf(
"%d / %d 项",
len(visible),
shell.model.TotalCount(),
),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(visible) == 0 {
return shell.layoutEmptyState(gtx, theme)
}
return shell.appList.Layout(gtx, len(visible), func(
gtx layout.Context,
index int,
) layout.Dimensions {
shell.lastRendered++
return shell.layoutAppRow(gtx, theme, visible[index])
})
}),
)
},
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasSelection {
return layout.Dimensions{}
}
return layout.Spacer{Width: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasSelection {
return layout.Dimensions{}
}
width := gtx.Dp(unit.Dp(280))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return shell.layoutDetail(gtx, theme, selected)
}),
)
}),
)
}
func (shell *AppShell) layoutAppRow(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
controls := shell.rows[item.ID]
if controls == nil {
return layout.Dimensions{}
}
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(80))
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
semantic.DescriptionOp(fmt.Sprintf(
"%s,版本 %s,状态 %s",
item.Name,
item.Version,
statusLabel(item.Status),
)).Add(gtx.Ops)
background := shellColors.muted
if controls.open.Hovered() || gtx.Focused(&controls.open) {
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
}
if shell.model.SelectedID() == item.ID {
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
}
return panel(
gtx,
background,
unit.Dp(5),
layout.UniformInset(unit.Dp(10)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(40),
unit.Dp(5),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.Body1(theme, item.Name).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
fmt.Sprintf("%s · %s · %s", item.ID, item.Version, item.Category),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, statusLabel(item.Status))
label.Color = statusColor(item.Status)
return label.Layout(gtx)
}),
)
},
)
})
})
}
func (shell *AppShell) layoutAppIcon(
gtx layout.Context,
theme *material.Theme,
appID string,
name string,
iconSize unit.Dp,
radius unit.Dp,
) layout.Dimensions {
size := gtx.Dp(iconSize)
gtx.Constraints.Min = image.Pt(size, size)
gtx.Constraints.Max = gtx.Constraints.Min
if icon, exists := shell.icons[appID]; exists {
return panel(
gtx,
shellColors.surface,
radius,
layout.UniformInset(unit.Dp(2)),
func(gtx layout.Context) layout.Dimensions {
return widget.Image{
Src: icon,
Fit: widget.Contain,
Position: layout.Center,
}.Layout(gtx)
},
)
}
letter := "S"
for _, character := range name {
letter = string(character)
break
}
return panel(
gtx,
shellColors.primary,
radius,
layout.UniformInset(unit.Dp(0)),
func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.Body1(theme, letter)
label.Color = shellColors.onPrimary
return label.Layout(gtx)
})
},
)
}
func (shell *AppShell) layoutDetail(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
shell.detailRendered = true
return panel(
gtx,
shellColors.muted,
unit.Dp(6),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.Body1(theme, "软件详情").Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.closeDetail,
"关闭",
false,
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(64),
unit.Dp(7),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, material.Body1(theme, item.Name).Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
fmt.Sprintf("%s · %s", item.ID, item.Version),
)
label.Color = shellColors.secondary
return layout.Center.Layout(gtx, label.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "状态", statusLabel(item.Status))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"标签",
fallbackText(strings.Join(item.Tags, " · "), "无"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"简介",
fallbackText(item.Description, "暂无简介"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Reason == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Tutorial == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "教程", item.Tutorial)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Homepage == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "主页", item.Homepage)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "实际安装/启动操作将在后续用例接入")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
},
)
}
func (shell *AppShell) layoutEmptyState(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
title := "没有匹配的软件"
body := "清除搜索词、分类或视图筛选后重试。"
showReset := shell.model.TotalCount() > 0
if shell.model.TotalCount() == 0 {
title = "软件目录尚未加载"
body = "联网刷新或读取已验证缓存后会显示软件。"
}
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, body)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showReset {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.resetFilters,
"显示全部软件",
true,
)
})
}),
)
})
}
func (shell *AppShell) layoutViewButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
view application.CatalogView,
) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
clickable,
label,
shell.model.View() == view,
)
}
func (shell *AppShell) layoutFilterButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
active bool,
) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, clickable, label)
button.CornerRadius = unit.Dp(5)
button.Inset = layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(12), Right: unit.Dp(12),
}
if active {
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
} else {
button.Background = shellColors.muted
button.Color = shellColors.foreground
}
return button.Layout(gtx)
}
func panel(
gtx layout.Context,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return layout.Background{}.Layout(
gtx,
func(gtx layout.Context) layout.Dimensions {
paint.FillShape(
gtx.Ops,
background,
clip.UniformRRect(
image.Rectangle{Max: gtx.Constraints.Min},
gtx.Dp(radius),
).Op(gtx.Ops),
)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, content)
},
)
}
func outlinedPanel(
gtx layout.Context,
border color.NRGBA,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return panel(
gtx,
border,
radius,
layout.UniformInset(unit.Dp(1)),
func(gtx layout.Context) layout.Dimensions {
return panel(gtx, background, radius-unit.Dp(1), inset, content)
},
)
}
func viewTitle(view application.CatalogView) string {
switch view {
case application.CatalogViewInstalled:
return "已安装软件"
case application.CatalogViewUpdates:
return "可更新软件"
default:
return "全部软件"
}
}
func statusLabel(status domain.AppStatus) string {
switch status {
case domain.StatusQueued:
return "排队中"
case domain.StatusDownloading:
return "下载中"
case domain.StatusVerifying:
return "校验中"
case domain.StatusExtracting:
return "解压中"
case domain.StatusInstalling:
return "安装中"
case domain.StatusInstalled:
return "已安装"
case domain.StatusUpdateAvailable:
return "可更新"
case domain.StatusRunning:
return "运行中"
case domain.StatusFailed:
return "失败"
case domain.StatusRollbackPending:
return "待恢复"
case domain.StatusIncompatible:
return "不兼容"
default:
return "未安装"
}
}
func statusColor(status domain.AppStatus) color.NRGBA {
switch status {
case domain.StatusFailed, domain.StatusRollbackPending:
return shellColors.destructive
case domain.StatusUpdateAvailable:
return shellColors.warning
case domain.StatusInstalled, domain.StatusRunning:
return shellColors.success
case domain.StatusIncompatible:
return shellColors.secondary
default:
return shellColors.primary
}
}
func detailField(
gtx layout.Context,
theme *material.Theme,
labelText string,
value string,
) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, labelText)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(theme, value).Layout),
)
})
}
func fallbackText(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func reasonLabel(reason string) string {
switch reason {
case "deprecated":
return "软件已停止发布"
case "minimum_os":
return "Windows 版本低于最低要求"
case "architecture":
return "没有当前架构的软件包"
default:
return reason
}
}
+131
View File
@@ -0,0 +1,131 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
func TestAppShellFillsWindow(t *testing.T) {
var operations op.Ops
size := image.Pt(1024, 680)
context := layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
dimensions := NewAppShell("Legacy").Layout(context, NewTheme())
if dimensions.Size != size {
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
}
}
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
items := make([]application.CatalogListItem, 500)
for index := range items {
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("软件 %03d", index),
Version: "1.0.0",
Category: "工具",
Tags: []string{"工具"},
Status: domain.StatusNotInstalled,
Installable: true,
}
}
shell := NewAppShell("Legacy", items...)
shell.Layout(testContext(image.Pt(1024, 380)), NewTheme())
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf(
"lastRendered = %d, want visible subset of %d",
shell.lastRendered,
len(items),
)
}
}
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
items := []application.CatalogListItem{
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
}
shell := NewAppShell("Legacy", items...)
original := shell.rows["app-two"]
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
shell.model.SetCategory("图像")
shell.Layout(testContext(image.Pt(1024, 680)), NewTheme())
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after filtering")
}
shell.SetItems([]application.CatalogListItem{
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
})
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after snapshot update")
}
if _, exists := shell.rows["app-one"]; exists {
t.Fatal("removed app retained row controls")
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app retained prepared icon")
}
icon, exists := shell.icons["app-two"]
if !exists {
t.Fatal("retained app lost its prepared icon")
}
if icon.Size() != image.Pt(24, 24) {
t.Fatalf("retained app icon size = %v", icon.Size())
}
}
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
item := application.CatalogListItem{
ID: "json-parser",
Name: "JSON解析工具",
Description: "格式化并检查 JSON",
Version: "1.2.0",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Homepage: "https://example.invalid/json-parser",
Tutorial: "https://example.invalid/json-parser/tutorial",
Status: domain.StatusInstalled,
Installed: true,
}
shell := NewAppShell("Legacy", item)
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
shell.ApplyIcon(item.ID, icon)
shell.model.Select(item.ID)
shell.Layout(testContext(image.Pt(1200, 760)), NewTheme())
if !shell.detailRendered {
t.Fatal("selected app detail was not rendered")
}
if _, exists := shell.icons[item.ID]; !exists {
t.Fatal("ApplyIcon did not retain the prepared image operation")
}
shell.ApplyIcon(item.ID, nil)
if _, exists := shell.icons[item.ID]; exists {
t.Fatal("ApplyIcon(nil) did not remove the image")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
}
+247
View File
@@ -0,0 +1,247 @@
package application
import (
"strings"
"softbox.local/core/domain"
)
// CatalogView is one primary software-list scope.
type CatalogView string
const (
CatalogViewAll CatalogView = "all"
CatalogViewInstalled CatalogView = "installed"
CatalogViewUpdates CatalogView = "updates"
)
// CatalogListItem is the IO-free data consumed by Gio list adapters.
type CatalogListItem struct {
ID string
Name string
Description string
Version string
Category string
Tags []string
IconRef string
Homepage string
Tutorial string
Status domain.AppStatus
Installed bool
Installable bool
Reason string
}
// CatalogListModel owns source items and composable list filters.
type CatalogListModel struct {
items []CatalogListItem
visible []CatalogListItem
categories []string
query string
category string
view CatalogView
selectedID string
}
// NewCatalogListModel copies items and initializes the all view.
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
model := &CatalogListModel{view: CatalogViewAll}
model.SetItems(items)
return model
}
// SetItems replaces the source snapshot and recomputes categories/visibility.
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
model.items = cloneCatalogItems(items)
model.categories = collectCategories(model.items)
if model.category != "" && !containsString(model.categories, model.category) {
model.category = ""
}
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
model.selectedID = ""
}
model.refilter()
}
// SetQuery applies a case-insensitive name/ID/tag search.
func (model *CatalogListModel) SetQuery(query string) {
normalized := strings.ToLower(strings.TrimSpace(query))
if model.query == normalized {
return
}
model.query = normalized
model.refilter()
}
// SetCategory selects one exact category. Empty means every category.
func (model *CatalogListModel) SetCategory(category string) {
if category != "" && !containsString(model.categories, category) {
return
}
if model.category == category {
return
}
model.category = category
model.refilter()
}
// SetView selects the all, installed or updates scope.
func (model *CatalogListModel) SetView(view CatalogView) {
if !view.Valid() || model.view == view {
return
}
model.view = view
model.refilter()
}
// Select records a stable app ID for row interaction state.
func (model *CatalogListModel) Select(appID string) {
if appID == "" || containsItemID(model.items, appID) {
model.selectedID = appID
}
}
// ResetFilters restores the full list while retaining source data.
func (model *CatalogListModel) ResetFilters() {
model.query = ""
model.category = ""
model.view = CatalogViewAll
model.refilter()
}
// VisibleItems returns the current immutable-by-convention snapshot.
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
return model.visible
}
// Categories returns the stable first-seen category order.
func (model *CatalogListModel) Categories() []string {
return model.categories
}
// TotalCount returns the unfiltered source count.
func (model *CatalogListModel) TotalCount() int {
return len(model.items)
}
// Query returns the normalized active query.
func (model *CatalogListModel) Query() string {
return model.query
}
// Category returns the active exact category, or empty for all.
func (model *CatalogListModel) Category() string {
return model.category
}
// View returns the active primary scope.
func (model *CatalogListModel) View() CatalogView {
return model.view
}
// SelectedID returns the selected stable software ID.
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 ||
view == CatalogViewInstalled ||
view == CatalogViewUpdates
}
func (model *CatalogListModel) refilter() {
model.visible = model.visible[:0]
for _, item := range model.items {
if model.category != "" && item.Category != model.category {
continue
}
if !matchesView(item, model.view) {
continue
}
if model.query != "" && !matchesQuery(item, model.query) {
continue
}
model.visible = append(model.visible, item)
}
}
func matchesView(item CatalogListItem, view CatalogView) bool {
switch view {
case CatalogViewAll:
return true
case CatalogViewInstalled:
return item.Installed
case CatalogViewUpdates:
return item.Status == domain.StatusUpdateAvailable
default:
return false
}
}
func matchesQuery(item CatalogListItem, query string) bool {
if strings.Contains(strings.ToLower(item.Name), query) ||
strings.Contains(strings.ToLower(item.ID), query) {
return true
}
for _, tag := range item.Tags {
if strings.Contains(strings.ToLower(tag), query) {
return true
}
}
return false
}
func collectCategories(items []CatalogListItem) []string {
seen := make(map[string]struct{})
categories := make([]string, 0)
for _, item := range items {
if item.Category == "" {
continue
}
if _, exists := seen[item.Category]; exists {
continue
}
seen[item.Category] = struct{}{}
categories = append(categories, item.Category)
}
return categories
}
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
cloned := make([]CatalogListItem, len(items))
for index, item := range items {
cloned[index] = item
cloned[index].Tags = append([]string(nil), item.Tags...)
}
return cloned
}
func containsString(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
func containsItemID(items []CatalogListItem, appID string) bool {
for _, item := range items {
if item.ID == appID {
return true
}
}
return false
}
+112
View File
@@ -0,0 +1,112 @@
package application
import (
"testing"
"softbox.local/core/domain"
)
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{
ID: "json-parser",
Name: "JSON解析工具",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Status: domain.StatusInstalled,
Installed: true,
},
{
ID: "image-tool",
Name: "Image Tool",
Category: "图像",
Tags: []string{"PNG", "压缩"},
Status: domain.StatusUpdateAvailable,
Installed: true,
},
{
ID: "log-viewer",
Name: "日志查看器",
Category: "开发工具",
Tags: []string{"LOG", "诊断"},
Status: domain.StatusNotInstalled,
},
})
model.SetQuery(" png ")
assertVisibleIDs(t, model, "image-tool")
model.SetQuery("")
model.SetCategory("开发工具")
assertVisibleIDs(t, model, "json-parser", "log-viewer")
model.SetView(CatalogViewInstalled)
assertVisibleIDs(t, model, "json-parser")
model.SetCategory("")
model.SetView(CatalogViewUpdates)
assertVisibleIDs(t, model, "image-tool")
model.SetQuery("IMAGE-")
assertVisibleIDs(t, model, "image-tool")
}
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
items := []CatalogListItem{
{ID: "app-b", Name: "B", Category: "工具"},
{ID: "app-a", Name: "A", Category: "工具"},
}
model := NewCatalogListModel(items)
model.Select("app-a")
model.SetQuery("app")
assertVisibleIDs(t, model, "app-b", "app-a")
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() != "" {
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
}
}
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{ID: "one", Category: "开发"},
{ID: "two", Category: "图像"},
{ID: "three", Category: "开发"},
})
categories := model.Categories()
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
t.Fatalf("Categories = %#v", categories)
}
model.SetQuery("missing")
model.SetCategory("开发")
model.SetView(CatalogViewInstalled)
model.ResetFilters()
if model.Query() != "" ||
model.Category() != "" ||
model.View() != CatalogViewAll ||
len(model.VisibleItems()) != 3 {
t.Fatalf("model was not reset: %#v", model)
}
}
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
t.Helper()
visible := model.VisibleItems()
if len(visible) != len(want) {
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
}
for index, item := range visible {
if item.ID != want[index] {
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package application coordinates SoftBox use cases through injected ports.
package application
+45
View File
@@ -0,0 +1,45 @@
package application
// DownloadStartedPayload begins one transfer attempt. A later attempt may
// reset Done when a remote entity cannot be safely resumed.
type DownloadStartedPayload struct {
Attempt uint64
Done int64
TotalKnown bool
Total int64
}
// DownloadProgressPayload reports monotonic progress within one attempt.
type DownloadProgressPayload struct {
Attempt uint64
Done int64
TotalKnown bool
Total int64
SpeedBytesSec int64
}
// DownloadPausedPayload maps to domain queued while preserving pause intent.
type DownloadPausedPayload struct {
Attempt uint64
Done int64
}
// DownloadCompletedPayload means bytes are durably downloaded but still
// untrusted. T-302 must verify the signed Catalog identity, size and hashes.
type DownloadCompletedPayload struct {
Attempt uint64
Done int64
Path string
}
// DownloadFailedPayload carries a stable low-level transfer/storage code.
type DownloadFailedPayload struct {
Attempt uint64
Done int64
ErrorCode string
}
// DownloadCanceledPayload confirms cleanup and suppresses the old attempt.
type DownloadCanceledPayload struct {
Attempt uint64
}
+57
View File
@@ -0,0 +1,57 @@
package application
// EventType identifies an application event consumed by UI adapters.
type EventType string
const (
EventCatalogRefreshed EventType = "CatalogRefreshed"
EventCatalogRejected EventType = "CatalogRejected"
EventDownloadStarted EventType = "DownloadStarted"
EventDownloadProgress EventType = "DownloadProgress"
EventDownloadPaused EventType = "DownloadPaused"
EventDownloadCompleted EventType = "DownloadCompleted"
EventDownloadFailed EventType = "DownloadFailed"
EventDownloadCanceled EventType = "DownloadCanceled"
EventInstallCompleted EventType = "InstallCompleted"
EventInstallRolledBack EventType = "InstallRolledBack"
EventAppStarted EventType = "AppStarted"
EventAppExited EventType = "AppExited"
EventLicenseChanged EventType = "LicenseChanged"
EventIconReady EventType = "IconReady"
EventIconFailed EventType = "IconFailed"
)
var validEventTypes = map[EventType]struct{}{
EventCatalogRefreshed: {},
EventCatalogRejected: {},
EventDownloadStarted: {},
EventDownloadProgress: {},
EventDownloadPaused: {},
EventDownloadCompleted: {},
EventDownloadFailed: {},
EventDownloadCanceled: {},
EventInstallCompleted: {},
EventInstallRolledBack: {},
EventAppStarted: {},
EventAppExited: {},
EventLicenseChanged: {},
EventIconReady: {},
EventIconFailed: {},
}
// Valid reports whether eventType is part of the documented event contract.
func (eventType EventType) Valid() bool {
_, ok := validEventTypes[eventType]
return ok
}
// Event is the common envelope delivered from background use cases to adapters.
//
// Payload is event-specific and will be replaced by concrete payload types as
// the corresponding use cases are implemented.
type Event struct {
Type EventType
RequestID string
AppID string
Payload any
}
+138
View File
@@ -0,0 +1,138 @@
package application
import (
"context"
"errors"
"fmt"
"sync"
)
var (
ErrEventRelayClosed = errors.New("application event relay closed")
ErrEventRelayInvalid = errors.New("application event relay is invalid")
)
// EventRelay is a bounded FIFO between background event pumps and the UI frame.
// Submit applies lossless backpressure; Drain must only run on the UI goroutine.
type EventRelay struct {
events chan Event
slots chan struct{}
done chan struct{}
mu sync.Mutex
closed bool
closeOnce sync.Once
}
// NewEventRelay creates a relay with a strictly positive bounded capacity.
func NewEventRelay(capacity int) (*EventRelay, error) {
if capacity <= 0 {
return nil, fmt.Errorf(
"%w: capacity must be positive",
ErrEventRelayInvalid,
)
}
relay := &EventRelay{
events: make(chan Event, capacity),
slots: make(chan struct{}, capacity),
done: make(chan struct{}),
}
for index := 0; index < capacity; index++ {
relay.slots <- struct{}{}
}
return relay, nil
}
// Submit queues one event or returns when the context/relay closes.
func (relay *EventRelay) Submit(ctx context.Context, event Event) error {
if relay == nil {
return fmt.Errorf("%w: nil relay", ErrEventRelayInvalid)
}
if !event.Type.Valid() {
return fmt.Errorf(
"%w: unknown type %q",
ErrInvalidEvent,
event.Type,
)
}
select {
case <-relay.done:
return ErrEventRelayClosed
case <-ctx.Done():
return ctx.Err()
case <-relay.slots:
}
relay.mu.Lock()
defer relay.mu.Unlock()
if relay.closed {
relay.slots <- struct{}{}
return ErrEventRelayClosed
}
if err := ctx.Err(); err != nil {
relay.slots <- struct{}{}
return err
}
relay.events <- event
return nil
}
// Drain applies the events present at entry without extending a UI frame forever.
func (relay *EventRelay) Drain(apply func(Event) error) error {
if relay == nil || apply == nil {
return fmt.Errorf("%w: nil relay or apply function", ErrEventRelayInvalid)
}
limit := len(relay.events)
var applyErrors []error
for index := 0; index < limit; index++ {
select {
case event := <-relay.events:
relay.slots <- struct{}{}
if err := apply(event); err != nil {
applyErrors = append(applyErrors, err)
}
default:
return errors.Join(applyErrors...)
}
}
return errors.Join(applyErrors...)
}
// Close unblocks pending submissions. Queued events remain available to Drain.
func (relay *EventRelay) Close() {
if relay == nil {
return
}
relay.closeOnce.Do(func() {
relay.mu.Lock()
relay.closed = true
close(relay.done)
relay.mu.Unlock()
})
}
// PumpEvents forwards application events to a relay and requests a UI frame.
// invalidate may be called concurrently; no UI state may be mutated here.
func PumpEvents(
ctx context.Context,
events <-chan Event,
relay *EventRelay,
invalidate func(),
) error {
if events == nil || relay == nil || invalidate == nil {
return fmt.Errorf("%w: incomplete event pump", ErrEventRelayInvalid)
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case event, open := <-events:
if !open {
return nil
}
if err := relay.Submit(ctx, event); err != nil {
return err
}
invalidate()
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package application
import (
"context"
"errors"
"reflect"
"testing"
"time"
)
func TestEventRelayUsesBoundedFIFOBackpressure(t *testing.T) {
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
first := Event{Type: EventCatalogRefreshed, RequestID: "first"}
second := Event{Type: EventCatalogRejected, RequestID: "second"}
if err := relay.Submit(context.Background(), first); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
secondResult := make(chan error, 1)
go func() {
close(started)
secondResult <- relay.Submit(context.Background(), second)
}()
<-started
select {
case err := <-secondResult:
t.Fatalf("second Submit() completed while relay was full: %v", err)
default:
}
var received []string
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
if err := waitRelayResult(secondResult); err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, []string{"first", "second"}) {
t.Fatalf("received order = %v", received)
}
}
func TestEventRelayCloseAndContextUnblockFullSubmit(t *testing.T) {
tests := []struct {
name string
unblock func(*EventRelay, context.CancelFunc)
wantErr error
}{
{
name: "close",
unblock: func(relay *EventRelay, _ context.CancelFunc) {
relay.Close()
},
wantErr: ErrEventRelayClosed,
},
{
name: "cancel",
unblock: func(_ *EventRelay, cancel context.CancelFunc) {
cancel()
},
wantErr: context.Canceled,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
if err := relay.Submit(
context.Background(),
Event{Type: EventCatalogRefreshed},
); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
result := make(chan error, 1)
go func() {
close(started)
result <- relay.Submit(ctx, Event{Type: EventCatalogRejected})
}()
<-started
test.unblock(relay, cancel)
if err := waitRelayResult(result); !errors.Is(err, test.wantErr) {
t.Fatalf("Submit() error = %v, want %v", err, test.wantErr)
}
})
}
}
func TestPumpEventsInvalidatesBeforeUIDrainAndStops(t *testing.T) {
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
source := make(chan Event, 2)
invalidated := make(chan struct{}, 2)
ctx, cancel := context.WithCancel(context.Background())
pumpResult := make(chan error, 1)
go func() {
pumpResult <- PumpEvents(ctx, source, relay, func() {
invalidated <- struct{}{}
})
}()
first := Event{Type: EventCatalogRefreshed, RequestID: "first"}
second := Event{Type: EventCatalogRejected, RequestID: "second"}
source <- first
source <- second
waitSignal(t, invalidated)
var received []string
if len(received) != 0 {
t.Fatal("background pump applied an event before UI drain")
}
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
waitSignal(t, invalidated)
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, []string{"first", "second"}) {
t.Fatalf("received order = %v", received)
}
cancel()
if err := waitRelayResult(pumpResult); !errors.Is(err, context.Canceled) {
t.Fatalf("PumpEvents() error = %v", err)
}
}
func TestEventRelayDrainReportsErrorsAndContinues(t *testing.T) {
relay, err := NewEventRelay(2)
if err != nil {
t.Fatal(err)
}
for _, eventType := range []EventType{EventCatalogRefreshed, EventCatalogRejected} {
if err := relay.Submit(context.Background(), Event{Type: eventType}); err != nil {
t.Fatal(err)
}
}
wantErr := errors.New("apply failed")
applied := 0
err = relay.Drain(func(Event) error {
applied++
return wantErr
})
if !errors.Is(err, wantErr) || applied != 2 {
t.Fatalf("Drain() = (%d applies, %v)", applied, err)
}
}
func TestNewEventRelayRejectsInvalidCapacity(t *testing.T) {
if _, err := NewEventRelay(0); !errors.Is(err, ErrEventRelayInvalid) {
t.Fatalf("NewEventRelay(0) error = %v", err)
}
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
relay.Close()
if err := relay.Submit(
context.Background(),
Event{Type: EventCatalogRefreshed},
); !errors.Is(err, ErrEventRelayClosed) {
t.Fatalf("Submit(after Close) error = %v", err)
}
}
func waitRelayResult(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for relay")
}
}
func waitSignal(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for signal")
}
}
+33
View File
@@ -0,0 +1,33 @@
package application
import "testing"
func TestEventTypeValid(t *testing.T) {
eventTypes := []EventType{
EventCatalogRefreshed,
EventCatalogRejected,
EventDownloadStarted,
EventDownloadProgress,
EventDownloadPaused,
EventDownloadCompleted,
EventDownloadFailed,
EventDownloadCanceled,
EventInstallCompleted,
EventInstallRolledBack,
EventAppStarted,
EventAppExited,
EventLicenseChanged,
EventIconReady,
EventIconFailed,
}
for _, eventType := range eventTypes {
if !eventType.Valid() {
t.Errorf("event type %q should be valid", eventType)
}
}
if EventType("Unknown").Valid() {
t.Fatal("unknown event type should be invalid")
}
}
+270
View File
@@ -0,0 +1,270 @@
package application
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"reflect"
"strings"
)
const (
MinIconDPI = 48
MaxIconDPI = 768
)
var ErrInvalidIconEvent = errors.New("invalid icon application event")
// IconFailureCode is a stable, non-sensitive reason exposed to UI adapters.
type IconFailureCode string
const (
IconFailureUnavailable IconFailureCode = "unavailable"
IconFailureInvalid IconFailureCode = "invalid_content"
IconFailureUnsafe IconFailureCode = "unsafe_cache"
)
// Valid reports whether code is part of the documented icon event contract.
func (code IconFailureCode) Valid() bool {
switch code {
case IconFailureUnavailable, IconFailureInvalid, IconFailureUnsafe:
return true
default:
return false
}
}
// IconEventIdentity correlates one background request with one catalog icon.
type IconEventIdentity struct {
RequestID string
AppID string
Reference string
DPI int
}
// NewIconEventIdentity validates and canonicalizes an icon event identity.
func NewIconEventIdentity(
requestID string,
appID string,
reference string,
dpi int,
) (IconEventIdentity, error) {
if strings.TrimSpace(requestID) == "" {
return IconEventIdentity{}, fmt.Errorf(
"%w: empty request ID",
ErrInvalidIconEvent,
)
}
if strings.TrimSpace(appID) == "" {
return IconEventIdentity{}, fmt.Errorf(
"%w: empty app ID",
ErrInvalidIconEvent,
)
}
canonicalReference, err := NormalizeIconReference(reference)
if err != nil {
return IconEventIdentity{}, err
}
if dpi < MinIconDPI || dpi > MaxIconDPI {
return IconEventIdentity{}, fmt.Errorf(
"%w: DPI %d outside %d..%d",
ErrInvalidIconEvent,
dpi,
MinIconDPI,
MaxIconDPI,
)
}
return IconEventIdentity{
RequestID: requestID,
AppID: appID,
Reference: canonicalReference,
DPI: dpi,
}, nil
}
// NormalizeIconReference returns the canonical sha256:<lower-hex> form.
func NormalizeIconReference(reference string) (string, error) {
const prefix = "sha256:"
if !strings.HasPrefix(reference, prefix) {
return "", fmt.Errorf(
"%w: icon reference must use sha256",
ErrInvalidIconEvent,
)
}
digest := strings.ToLower(strings.TrimPrefix(reference, prefix))
decoded, err := hex.DecodeString(digest)
if err != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 {
return "", fmt.Errorf(
"%w: malformed icon digest",
ErrInvalidIconEvent,
)
}
return prefix + digest, nil
}
// IconReadyPayload contains an image decoded outside the Gio UI goroutine.
type IconReadyPayload struct {
Reference string
DPI int
Image image.Image
}
// IconFailedPayload contains a stable failure classification, never a raw URL.
type IconFailedPayload struct {
Reference string
DPI int
ErrorCode IconFailureCode
}
// IconEvent is the validated representation consumed by UI adapters.
type IconEvent struct {
Type EventType
Identity IconEventIdentity
Image image.Image
ErrorCode IconFailureCode
}
// NewIconReadyEvent builds a validated ready event.
func NewIconReadyEvent(
identity IconEventIdentity,
icon image.Image,
) (Event, error) {
validated, err := NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return Event{}, err
}
if isNilImage(icon) {
return Event{}, fmt.Errorf("%w: nil ready image", ErrInvalidIconEvent)
}
return Event{
Type: EventIconReady,
RequestID: validated.RequestID,
AppID: validated.AppID,
Payload: IconReadyPayload{
Reference: validated.Reference,
DPI: validated.DPI,
Image: icon,
},
}, nil
}
// NewIconFailedEvent builds a validated failure event.
func NewIconFailedEvent(
identity IconEventIdentity,
code IconFailureCode,
) (Event, error) {
validated, err := NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return Event{}, err
}
if !code.Valid() {
return Event{}, fmt.Errorf(
"%w: unknown failure code %q",
ErrInvalidIconEvent,
code,
)
}
return Event{
Type: EventIconFailed,
RequestID: validated.RequestID,
AppID: validated.AppID,
Payload: IconFailedPayload{
Reference: validated.Reference,
DPI: validated.DPI,
ErrorCode: code,
},
}, nil
}
// ParseIconEvent validates an icon envelope. Non-icon events return handled=false.
func ParseIconEvent(event Event) (parsed IconEvent, handled bool, err error) {
switch event.Type {
case EventIconReady:
payload, ok := event.Payload.(IconReadyPayload)
if !ok {
return IconEvent{}, true, fmt.Errorf(
"%w: ready payload has type %T",
ErrInvalidIconEvent,
event.Payload,
)
}
identity, identityErr := NewIconEventIdentity(
event.RequestID,
event.AppID,
payload.Reference,
payload.DPI,
)
if identityErr != nil {
return IconEvent{}, true, identityErr
}
if isNilImage(payload.Image) {
return IconEvent{}, true, fmt.Errorf(
"%w: nil ready image",
ErrInvalidIconEvent,
)
}
return IconEvent{
Type: event.Type,
Identity: identity,
Image: payload.Image,
}, true, nil
case EventIconFailed:
payload, ok := event.Payload.(IconFailedPayload)
if !ok {
return IconEvent{}, true, fmt.Errorf(
"%w: failed payload has type %T",
ErrInvalidIconEvent,
event.Payload,
)
}
identity, identityErr := NewIconEventIdentity(
event.RequestID,
event.AppID,
payload.Reference,
payload.DPI,
)
if identityErr != nil {
return IconEvent{}, true, identityErr
}
if !payload.ErrorCode.Valid() {
return IconEvent{}, true, fmt.Errorf(
"%w: unknown failure code %q",
ErrInvalidIconEvent,
payload.ErrorCode,
)
}
return IconEvent{
Type: event.Type,
Identity: identity,
ErrorCode: payload.ErrorCode,
}, true, nil
default:
return IconEvent{}, false, nil
}
}
func isNilImage(icon image.Image) bool {
if icon == nil {
return true
}
value := reflect.ValueOf(icon)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
+126
View File
@@ -0,0 +1,126 @@
package application
import (
"errors"
"image"
"strings"
"testing"
)
func TestIconEventRoundTrip(t *testing.T) {
reference := "sha256:" + strings.Repeat("A1", 32)
identity, err := NewIconEventIdentity("request-1", "app-one", reference, 144)
if err != nil {
t.Fatalf("NewIconEventIdentity() error = %v", err)
}
if identity.Reference != strings.ToLower(reference) {
t.Fatalf("canonical reference = %q", identity.Reference)
}
icon := image.NewNRGBA(image.Rect(0, 0, 24, 24))
ready, err := NewIconReadyEvent(identity, icon)
if err != nil {
t.Fatalf("NewIconReadyEvent() error = %v", err)
}
parsed, handled, err := ParseIconEvent(ready)
if err != nil || !handled {
t.Fatalf("ParseIconEvent(ready) = (%+v, %t, %v)", parsed, handled, err)
}
if parsed.Type != EventIconReady || parsed.Identity != identity || parsed.Image != icon {
t.Fatalf("parsed ready event = %+v", parsed)
}
failed, err := NewIconFailedEvent(identity, IconFailureUnsafe)
if err != nil {
t.Fatalf("NewIconFailedEvent() error = %v", err)
}
parsed, handled, err = ParseIconEvent(failed)
if err != nil || !handled {
t.Fatalf("ParseIconEvent(failed) = (%+v, %t, %v)", parsed, handled, err)
}
if parsed.Type != EventIconFailed ||
parsed.Identity != identity ||
parsed.ErrorCode != IconFailureUnsafe {
t.Fatalf("parsed failed event = %+v", parsed)
}
parsed, handled, err = ParseIconEvent(Event{Type: EventCatalogRefreshed})
if err != nil || handled {
t.Fatalf("ParseIconEvent(non-icon) = (%+v, %t, %v)", parsed, handled, err)
}
}
func TestIconEventRejectsInvalidIdentityAndPayload(t *testing.T) {
validReference := "sha256:" + strings.Repeat("0a", 32)
tests := []struct {
name string
requestID string
appID string
reference string
dpi int
}{
{name: "empty request", appID: "app", reference: validReference, dpi: 96},
{name: "empty app", requestID: "request", reference: validReference, dpi: 96},
{name: "bad reference", requestID: "request", appID: "app", reference: "md5:00", dpi: 96},
{name: "low DPI", requestID: "request", appID: "app", reference: validReference, dpi: 47},
{name: "high DPI", requestID: "request", appID: "app", reference: validReference, dpi: 769},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := NewIconEventIdentity(
test.requestID,
test.appID,
test.reference,
test.dpi,
)
if !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconEventIdentity() error = %v", err)
}
})
}
identity, err := NewIconEventIdentity("request", "app", validReference, 96)
if err != nil {
t.Fatal(err)
}
if _, err := NewIconReadyEvent(identity, nil); !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconReadyEvent(nil) error = %v", err)
}
var typedNil *image.NRGBA
if _, err := NewIconReadyEvent(identity, typedNil); !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconReadyEvent(typed nil) error = %v", err)
}
if _, err := NewIconFailedEvent(identity, IconFailureCode("raw-http-error")); !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconFailedEvent(invalid code) error = %v", err)
}
invalidPayloads := []Event{
{Type: EventIconReady, RequestID: "request", AppID: "app", Payload: "wrong"},
{Type: EventIconFailed, RequestID: "request", AppID: "app", Payload: "wrong"},
{
Type: EventIconReady,
RequestID: "request",
AppID: "app",
Payload: IconReadyPayload{
Reference: validReference,
DPI: 96,
},
},
{
Type: EventIconFailed,
RequestID: "request",
AppID: "app",
Payload: IconFailedPayload{
Reference: validReference,
DPI: 96,
ErrorCode: IconFailureCode("raw"),
},
},
}
for _, event := range invalidPayloads {
_, handled, err := ParseIconEvent(event)
if !handled || !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("ParseIconEvent(%+v) handled=%t error=%v", event, handled, err)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package application
import (
"context"
"errors"
"fmt"
"sync"
)
var (
// ErrInvalidEvent indicates that an event is not part of the event contract.
ErrInvalidEvent = errors.New("invalid application event")
// ErrRuntimeClosed indicates that the runtime no longer accepts events.
ErrRuntimeClosed = errors.New("application runtime closed")
)
// Runtime is the minimal event bus shared by background use cases and adapters.
type Runtime struct {
events chan Event
done chan struct{}
closeOnce sync.Once
}
// NewRuntime creates an event runtime with the requested queue capacity.
func NewRuntime(buffer int) *Runtime {
if buffer < 0 {
panic("application runtime buffer must not be negative")
}
return &Runtime{
events: make(chan Event, buffer),
done: make(chan struct{}),
}
}
// Publish queues an event or returns when the context/runtime is closed.
func (runtime *Runtime) Publish(ctx context.Context, event Event) error {
if !event.Type.Valid() {
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, event.Type)
}
select {
case <-runtime.done:
return ErrRuntimeClosed
default:
}
select {
case <-runtime.done:
return ErrRuntimeClosed
case <-ctx.Done():
return ctx.Err()
case runtime.events <- event:
return nil
}
}
// Events exposes the read-only event stream to adapters.
func (runtime *Runtime) Events() <-chan Event {
return runtime.events
}
// Done is closed when the runtime stops accepting events.
func (runtime *Runtime) Done() <-chan struct{} {
return runtime.done
}
// Close stops future publishes. It is safe to call more than once.
func (runtime *Runtime) Close() {
runtime.closeOnce.Do(func() {
close(runtime.done)
})
}
+66
View File
@@ -0,0 +1,66 @@
package application
import (
"context"
"errors"
"testing"
)
func TestRuntimePublish(t *testing.T) {
runtime := NewRuntime(1)
event := Event{
Type: EventDownloadStarted,
RequestID: "request-1",
AppID: "json-parser",
}
if err := runtime.Publish(context.Background(), event); err != nil {
t.Fatalf("Publish() error = %v", err)
}
got := <-runtime.Events()
if got != event {
t.Fatalf("Events() got %#v, want %#v", got, event)
}
}
func TestRuntimeRejectsInvalidEvent(t *testing.T) {
runtime := NewRuntime(1)
err := runtime.Publish(context.Background(), Event{Type: EventType("Unknown")})
if !errors.Is(err, ErrInvalidEvent) {
t.Fatalf("Publish() error = %v, want %v", err, ErrInvalidEvent)
}
}
func TestRuntimePublishHonorsContextCancellation(t *testing.T) {
runtime := NewRuntime(1)
if err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}); err != nil {
t.Fatalf("first Publish() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := runtime.Publish(ctx, Event{Type: EventDownloadProgress})
if !errors.Is(err, context.Canceled) {
t.Fatalf("blocked Publish() error = %v, want %v", err, context.Canceled)
}
}
func TestRuntimeClose(t *testing.T) {
runtime := NewRuntime(1)
runtime.Close()
runtime.Close()
select {
case <-runtime.Done():
default:
t.Fatal("Done() should be closed")
}
err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted})
if !errors.Is(err, ErrRuntimeClosed) {
t.Fatalf("Publish() error = %v, want %v", err, ErrRuntimeClosed)
}
}
+166
View File
@@ -0,0 +1,166 @@
package catalog
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"unicode/utf8"
)
var integerJSONNumber = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
func parseRestrictedJSON(data []byte) (any, error) {
if !utf8.Valid(data) {
return nil, fmt.Errorf("%w: input is not valid UTF-8", ErrInvalidDocument)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
value, err := decodeJSONValue(decoder)
if err != nil {
return nil, err
}
if _, err := decoder.Token(); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("%w: trailing JSON value", ErrInvalidDocument)
}
return nil, fmt.Errorf("%w: trailing data: %v", ErrInvalidDocument, err)
}
return value, nil
}
func decodeJSONValue(decoder *json.Decoder) (any, error) {
token, err := decoder.Token()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidDocument, err)
}
switch value := token.(type) {
case json.Delim:
switch value {
case '{':
object := make(map[string]any)
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return nil, fmt.Errorf("%w: object key: %v", ErrInvalidDocument, err)
}
key, ok := keyToken.(string)
if !ok {
return nil, fmt.Errorf("%w: object key is not a string", ErrInvalidDocument)
}
if _, exists := object[key]; exists {
return nil, fmt.Errorf("%w: %q", ErrDuplicateField, key)
}
child, err := decodeJSONValue(decoder)
if err != nil {
return nil, err
}
object[key] = child
}
end, err := decoder.Token()
if err != nil || end != json.Delim('}') {
return nil, fmt.Errorf("%w: unterminated object", ErrInvalidDocument)
}
return object, nil
case '[':
var array []any
for decoder.More() {
child, err := decodeJSONValue(decoder)
if err != nil {
return nil, err
}
array = append(array, child)
}
end, err := decoder.Token()
if err != nil || end != json.Delim(']') {
return nil, fmt.Errorf("%w: unterminated array", ErrInvalidDocument)
}
return array, nil
default:
return nil, fmt.Errorf("%w: unexpected delimiter %q", ErrInvalidDocument, value)
}
case json.Number:
if !integerJSONNumber.MatchString(string(value)) {
return nil, fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
}
return value, nil
case string, bool, nil:
return value, nil
default:
return nil, fmt.Errorf("%w: unsupported token %T", ErrInvalidDocument, token)
}
}
func canonicalJSON(value any) ([]byte, error) {
var buffer bytes.Buffer
if err := appendCanonicalJSON(&buffer, value); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func appendCanonicalJSON(buffer *bytes.Buffer, value any) error {
switch value := value.(type) {
case nil:
buffer.WriteString("null")
case bool:
if value {
buffer.WriteString("true")
} else {
buffer.WriteString("false")
}
case string:
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("%w: encode string: %v", ErrInvalidDocument, err)
}
buffer.Write(encoded)
case json.Number:
if !integerJSONNumber.MatchString(string(value)) {
return fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
}
buffer.WriteString(string(value))
case []any:
buffer.WriteByte('[')
for index, child := range value {
if index > 0 {
buffer.WriteByte(',')
}
if err := appendCanonicalJSON(buffer, child); err != nil {
return err
}
}
buffer.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
buffer.WriteByte('{')
for index, key := range keys {
if index > 0 {
buffer.WriteByte(',')
}
encodedKey, err := json.Marshal(key)
if err != nil {
return fmt.Errorf("%w: encode key: %v", ErrInvalidDocument, err)
}
buffer.Write(encodedKey)
buffer.WriteByte(':')
if err := appendCanonicalJSON(buffer, value[key]); err != nil {
return err
}
}
buffer.WriteByte('}')
default:
return fmt.Errorf("%w: unsupported value %T", ErrInvalidDocument, value)
}
return nil
}
+48
View File
@@ -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
}
+60
View File
@@ -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)
}
}
+105
View File
@@ -0,0 +1,105 @@
package catalog
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var ErrCachePathEmpty = errors.New("catalog cache path is empty")
// FileCache stores a signed Catalog document and a crash-recovery backup.
type FileCache struct {
path string
mu sync.Mutex
}
func NewFileCache(path string) *FileCache {
return &FileCache{path: path}
}
func (cache *FileCache) Load() ([]byte, error) {
cache.mu.Lock()
defer cache.mu.Unlock()
if cache.path == "" {
return nil, ErrCachePathEmpty
}
document, err := os.ReadFile(cache.path)
if err == nil {
return document, nil
}
if !os.IsNotExist(err) {
return nil, err
}
return os.ReadFile(cache.backupPath())
}
func (cache *FileCache) Store(document []byte) error {
cache.mu.Lock()
defer cache.mu.Unlock()
if cache.path == "" {
return ErrCachePathEmpty
}
directory := filepath.Dir(cache.path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("create catalog cache directory: %w", err)
}
temporary, err := os.CreateTemp(directory, ".catalog-*.tmp")
if err != nil {
return fmt.Errorf("create catalog 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 catalog cache temp file: %w", err)
}
if _, err := temporary.Write(document); err != nil {
temporary.Close()
return fmt.Errorf("write catalog cache temp file: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("sync catalog cache temp file: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close catalog cache temp file: %w", err)
}
backupPath := cache.backupPath()
movedCurrent := false
if _, err := os.Stat(cache.path); err == nil {
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale catalog cache backup: %w", err)
}
if err := os.Rename(cache.path, backupPath); err != nil {
return fmt.Errorf("backup current catalog cache: %w", err)
}
movedCurrent = true
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect current catalog cache: %w", err)
}
if err := os.Rename(temporaryPath, cache.path); err != nil {
if movedCurrent {
_ = os.Rename(backupPath, cache.path)
}
return fmt.Errorf("activate catalog cache: %w", err)
}
if movedCurrent {
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove catalog cache backup: %w", err)
}
}
return nil
}
func (cache *FileCache) backupPath() string {
return cache.path + ".backup"
}
+49
View File
@@ -0,0 +1,49 @@
package catalog
import (
"os"
"path/filepath"
"testing"
)
func TestFileCacheStoreAndUpdate(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache", "manifest.json")
cache := NewFileCache(path)
if err := cache.Store([]byte("first")); err != nil {
t.Fatalf("Store(first) error = %v", err)
}
if err := cache.Store([]byte("second")); err != nil {
t.Fatalf("Store(second) error = %v", err)
}
got, err := cache.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if string(got) != "second" {
t.Fatalf("Load() = %q, want %q", got, "second")
}
if _, err := os.Stat(path + ".backup"); !os.IsNotExist(err) {
t.Fatalf("backup should be removed after successful update, stat error = %v", err)
}
}
func TestFileCacheLoadsBackupAfterInterruptedSwitch(t *testing.T) {
path := filepath.Join(t.TempDir(), "manifest.json")
cache := NewFileCache(path)
if err := cache.Store([]byte("verified")); err != nil {
t.Fatalf("Store() error = %v", err)
}
if err := os.Rename(path, path+".backup"); err != nil {
t.Fatalf("simulate interrupted switch: %v", err)
}
got, err := cache.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if string(got) != "verified" {
t.Fatalf("Load() = %q, want %q", got, "verified")
}
}
+58
View File
@@ -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]
}
+80
View File
@@ -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)
}
}
+105
View File
@@ -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
}
+82
View File
@@ -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)
}
})
}
+432
View File
@@ -0,0 +1,432 @@
package catalog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
const (
DefaultMaxIconBytes int64 = 2 << 20
DefaultMaxIconDimension = 2048
DefaultIconMemoryBytes int64 = 32 << 20
DefaultIconMemoryEntries = 256
UnknownIconContentLength int64 = -1
)
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")
ErrIconResponseInvalid = errors.New("icon fetch response 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
}
// IconFetchResponse streams one icon while preserving its optional declared size.
// Body ownership transfers to IconCache and reads must stop when the FetchIcon
// context is canceled.
type IconFetchResponse struct {
Body io.ReadCloser
ContentLength int64
}
// IconFetcher opens an icon response outside Gio Layout.
type IconFetcher interface {
FetchIcon(context.Context, IconRequest) (IconFetchResponse, error)
}
// IconFetchFunc adapts a function to IconFetcher.
type IconFetchFunc func(context.Context, IconRequest) (IconFetchResponse, error)
func (function IconFetchFunc) FetchIcon(
ctx context.Context,
request IconRequest,
) (IconFetchResponse, 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 *iconMemoryCache
inflight map[string]*iconFlight
}
type iconFlight struct {
done chan struct{}
result IconResult
err error
}
// 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: newIconMemoryCache(
DefaultIconMemoryBytes,
DefaultIconMemoryEntries,
),
inflight: make(map[string]*iconFlight),
}
}
// Load resolves memory, verified disk, then verified remote bytes.
func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResult, error) {
digest, key, err := cacheKey(request)
if err != nil {
return IconResult{}, err
}
cache.mu.Lock()
if document, exists := cache.memory.get(key); exists {
cache.mu.Unlock()
return IconResult{
Bytes: document,
Source: IconSourceMemory,
}, nil
}
if flight, exists := cache.inflight[key]; exists {
cache.mu.Unlock()
select {
case <-flight.done:
return cloneIconResult(flight.result), flight.err
case <-ctx.Done():
return IconResult{}, ctx.Err()
}
}
flight := &iconFlight{done: make(chan struct{})}
cache.inflight[key] = flight
cache.mu.Unlock()
result, loadErr := cache.loadUncached(ctx, request, digest)
cache.mu.Lock()
if loadErr == nil {
cache.memory.put(key, result.Bytes)
}
flight.result = cloneIconResult(result)
flight.err = loadErr
delete(cache.inflight, key)
close(flight.done)
cache.mu.Unlock()
return cloneIconResult(result), loadErr
}
func (cache *IconCache) loadUncached(
ctx context.Context,
request IconRequest,
digest string,
) (IconResult, error) {
filePath, pathErr := cache.filePath(digest, request.DPI)
if pathErr != nil {
return IconResult{}, pathErr
}
document, diskErr := cache.loadDisk(filePath, digest)
if diskErr == nil {
return IconResult{
Bytes: 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"),
}
}
response, fetchErr := cache.fetcher.FetchIcon(ctx, request)
if fetchErr != nil {
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr}
}
document, fetchErr = cache.readFetchedIcon(ctx, response)
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)
return IconResult{
Bytes: document,
Source: IconSourceRemote,
Warning: storeErr,
}, nil
}
func (cache *IconCache) readFetchedIcon(
ctx context.Context,
response IconFetchResponse,
) (document []byte, resultErr error) {
if response.Body == nil {
return nil, fmt.Errorf("%w: nil body", ErrIconResponseInvalid)
}
defer func() {
if closeErr := response.Body.Close(); resultErr == nil && closeErr != nil {
resultErr = fmt.Errorf("close icon response: %w", closeErr)
}
}()
if response.ContentLength < UnknownIconContentLength {
return nil, fmt.Errorf(
"%w: content length %d",
ErrIconResponseInvalid,
response.ContentLength,
)
}
maxBytes := cache.iconByteLimit()
if response.ContentLength > maxBytes {
return nil, fmt.Errorf(
"%w: declared bytes=%d limit=%d",
ErrIconTooLarge,
response.ContentLength,
maxBytes,
)
}
if err := ctx.Err(); err != nil {
return nil, err
}
document, readErr := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
if readErr != nil {
return nil, fmt.Errorf("read icon response: %w", readErr)
}
if int64(len(document)) > maxBytes {
return nil, fmt.Errorf(
"%w: bytes=%d limit=%d",
ErrIconTooLarge,
len(document),
maxBytes,
)
}
return document, nil
}
func (cache *IconCache) iconByteLimit() int64 {
if cache.maxBytes <= 0 {
return DefaultMaxIconBytes
}
return cache.maxBytes
}
func cloneIconResult(result IconResult) IconResult {
result.Bytes = append([]byte(nil), result.Bytes...)
return result
}
// 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.iconByteLimit()
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
}
+411
View File
@@ -0,0 +1,411 @@
package catalog
import (
"bytes"
"context"
"errors"
"os"
"sync/atomic"
"testing"
"time"
)
type iconLoadOutcome struct {
result IconResult
err error
}
func TestIconCacheMemoryHitDoesNotWaitForDifferentKey(t *testing.T) {
fastDocument := testPNG(t, 15, 15)
slowDocument := testPNG(t, 16, 16)
fastRequest := iconRequest(fastDocument, 96)
slowRequest := iconRequest(slowDocument, 96)
slowStarted := make(chan struct{})
releaseSlow := make(chan struct{})
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
ctx context.Context,
request IconRequest,
) (IconFetchResponse, error) {
if request.Reference == slowRequest.Reference {
close(slowStarted)
select {
case <-releaseSlow:
case <-ctx.Done():
return IconFetchResponse{}, ctx.Err()
}
return iconResponse(slowDocument), nil
}
return iconResponse(fastDocument), nil
}))
if _, err := cache.Load(context.Background(), fastRequest); err != nil {
t.Fatalf("Load(seed memory) error = %v", err)
}
slowOutcome := loadIconAsync(cache, context.Background(), slowRequest)
waitSignal(t, slowStarted, "slow fetch did not start")
fastOutcome := loadIconAsync(cache, context.Background(), fastRequest)
select {
case outcome := <-fastOutcome:
if outcome.err != nil || outcome.result.Source != IconSourceMemory {
t.Fatalf("memory outcome = %#v, %v", outcome.result, outcome.err)
}
case <-time.After(2 * time.Second):
close(releaseSlow)
t.Fatal("memory hit waited for an unrelated slow fetch")
}
close(releaseSlow)
if outcome := waitOutcome(t, slowOutcome); outcome.err != nil {
t.Fatalf("slow Load() error = %v", outcome.err)
}
}
func TestIconCacheFetchesDifferentKeysConcurrently(t *testing.T) {
documents := [][]byte{testPNG(t, 17, 16), testPNG(t, 18, 16)}
requests := []IconRequest{
iconRequest(documents[0], 96),
iconRequest(documents[1], 96),
}
documentByReference := map[string][]byte{
requests[0].Reference: documents[0],
requests[1].Reference: documents[1],
}
started := make(chan string, len(requests))
release := make(chan struct{})
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
ctx context.Context,
request IconRequest,
) (IconFetchResponse, error) {
started <- request.Reference
select {
case <-release:
return iconResponse(documentByReference[request.Reference]), nil
case <-ctx.Done():
return IconFetchResponse{}, ctx.Err()
}
}))
first := loadIconAsync(cache, context.Background(), requests[0])
waitSignal(t, started, "first fetch did not start")
second := loadIconAsync(cache, context.Background(), requests[1])
waitSignal(t, started, "second key did not fetch while first key was blocked")
close(release)
for index, outcomeChannel := range []<-chan iconLoadOutcome{first, second} {
outcome := waitOutcome(t, outcomeChannel)
if outcome.err != nil || outcome.result.Source != IconSourceRemote {
t.Fatalf("outcome %d = %#v, %v", index, outcome.result, outcome.err)
}
}
}
func TestIconCacheCoalescesSameKeyAndReturnsIndependentBytes(t *testing.T) {
document := testPNG(t, 19, 16)
request := iconRequest(document, 96)
started := make(chan struct{})
release := make(chan struct{})
var fetchCalls int32
root := t.TempDir()
cache := NewIconCache(root, IconFetchFunc(func(
ctx context.Context,
_ IconRequest,
) (IconFetchResponse, error) {
atomic.AddInt32(&fetchCalls, 1)
close(started)
select {
case <-release:
return iconResponse(document), nil
case <-ctx.Done():
return IconFetchResponse{}, ctx.Err()
}
}))
leader := loadIconAsync(cache, context.Background(), request)
waitSignal(t, started, "leader fetch did not start")
cache.mu.Lock()
follower := loadIconAsync(cache, context.Background(), request)
close(release)
cache.mu.Unlock()
leaderOutcome := waitOutcome(t, leader)
followerOutcome := waitOutcome(t, follower)
if leaderOutcome.err != nil || followerOutcome.err != nil {
t.Fatalf("coalesced errors = %v, %v", leaderOutcome.err, followerOutcome.err)
}
if got := atomic.LoadInt32(&fetchCalls); got != 1 {
t.Fatalf("fetch calls = %d, want 1", got)
}
if !bytes.Equal(leaderOutcome.result.Bytes, followerOutcome.result.Bytes) {
t.Fatal("coalesced callers received different content")
}
leaderOutcome.result.Bytes[0] ^= 0xff
if bytes.Equal(leaderOutcome.result.Bytes, followerOutcome.result.Bytes) {
t.Fatal("coalesced callers shared a mutable backing array")
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 1 {
t.Fatalf("disk entries = %v, error=%v; want one", entries, err)
}
}
func TestIconCacheFollowerCancellationDoesNotCancelLeader(t *testing.T) {
document := testPNG(t, 20, 16)
request := iconRequest(document, 96)
started := make(chan struct{})
release := make(chan struct{})
var fetchCalls int32
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
ctx context.Context,
_ IconRequest,
) (IconFetchResponse, error) {
atomic.AddInt32(&fetchCalls, 1)
close(started)
select {
case <-release:
return iconResponse(document), nil
case <-ctx.Done():
return IconFetchResponse{}, ctx.Err()
}
}))
leader := loadIconAsync(cache, context.Background(), request)
waitSignal(t, started, "leader fetch did not start")
followerContext, cancelFollower := context.WithCancel(context.Background())
cancelFollower()
if _, err := cache.Load(followerContext, request); !errors.Is(err, context.Canceled) {
t.Fatalf("follower error = %v, want context canceled", err)
}
if got := atomic.LoadInt32(&fetchCalls); got != 1 {
t.Fatalf("fetch calls after follower cancel = %d, want 1", got)
}
close(release)
if outcome := waitOutcome(t, leader); outcome.err != nil {
t.Fatalf("leader error = %v", outcome.err)
}
}
func TestIconCacheLeaderCancellationClearsFlightForRetry(t *testing.T) {
document := testPNG(t, 21, 16)
request := iconRequest(document, 96)
firstStarted := make(chan struct{})
var fetchCalls int32
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
ctx context.Context,
_ IconRequest,
) (IconFetchResponse, error) {
call := atomic.AddInt32(&fetchCalls, 1)
if call == 1 {
close(firstStarted)
<-ctx.Done()
return IconFetchResponse{}, ctx.Err()
}
return iconResponse(document), nil
}))
leaderContext, cancelLeader := context.WithCancel(context.Background())
first := loadIconAsync(cache, leaderContext, request)
waitSignal(t, firstStarted, "cancelable leader did not start")
cancelLeader()
if outcome := waitOutcome(t, first); !errors.Is(outcome.err, context.Canceled) {
t.Fatalf("leader error = %v, want context canceled", outcome.err)
}
cache.mu.Lock()
flights := len(cache.inflight)
cache.mu.Unlock()
if flights != 0 {
t.Fatalf("in-flight entries after failure = %d, want 0", flights)
}
result, err := cache.Load(context.Background(), request)
if err != nil || result.Source != IconSourceRemote {
t.Fatalf("retry result = %#v, error=%v", result, err)
}
if got := atomic.LoadInt32(&fetchCalls); got != 2 {
t.Fatalf("fetch calls after retry = %d, want 2", got)
}
}
func TestIconCacheBoundsAndClosesFetchedBodies(t *testing.T) {
document := bytes.Repeat([]byte{0x42}, 32)
request := iconRequest(document, 96)
tests := []struct {
name string
contentLength int64
wantRead int64
}{
{name: "declared oversized", contentLength: 9, wantRead: 0},
{name: "unknown oversized", contentLength: UnknownIconContentLength, wantRead: 9},
{name: "underdeclared oversized", contentLength: 1, wantRead: 9},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := newTrackingIconBody(document)
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
return IconFetchResponse{
Body: body,
ContentLength: test.contentLength,
}, nil
}))
cache.maxBytes = 8
_, err := cache.Load(context.Background(), request)
if !errors.Is(err, ErrIconTooLarge) {
t.Fatalf("Load() error = %v, want %v", err, ErrIconTooLarge)
}
if got := atomic.LoadInt64(&body.bytesRead); got != test.wantRead {
t.Fatalf("body bytes read = %d, want %d", got, test.wantRead)
}
if got := atomic.LoadInt32(&body.closed); got != 1 {
t.Fatalf("body close calls = %d, want 1", got)
}
})
}
}
func TestIconCacheClosesFetchedBodyOnValidationFailure(t *testing.T) {
document := testPNG(t, 25, 16)
request := iconRequest(testPNG(t, 26, 16), 96)
body := newTrackingIconBody(document)
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
return IconFetchResponse{
Body: body,
ContentLength: int64(len(document)),
}, nil
}))
_, err := cache.Load(context.Background(), request)
if !errors.Is(err, ErrIconHashMismatch) {
t.Fatalf("Load() error = %v, want %v", err, ErrIconHashMismatch)
}
if got := atomic.LoadInt64(&body.bytesRead); got != int64(len(document)) {
t.Fatalf("body bytes read = %d, want %d", got, len(document))
}
if got := atomic.LoadInt32(&body.closed); got != 1 {
t.Fatalf("body close calls = %d, want 1", got)
}
}
func TestIconCacheCanceledContextClosesBodyWithoutReading(t *testing.T) {
document := testPNG(t, 27, 16)
request := iconRequest(document, 96)
body := newTrackingIconBody(document)
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
return IconFetchResponse{
Body: body,
ContentLength: int64(len(document)),
}, nil
}))
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := cache.Load(ctx, request)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Load() error = %v, want context canceled", err)
}
if got := atomic.LoadInt64(&body.bytesRead); got != 0 {
t.Fatalf("body bytes read = %d, want 0", got)
}
if got := atomic.LoadInt32(&body.closed); got != 1 {
t.Fatalf("body close calls = %d, want 1", got)
}
}
func TestIconCacheRejectsInvalidFetchResponses(t *testing.T) {
document := testPNG(t, 28, 16)
request := iconRequest(document, 96)
t.Run("nil body", func(t *testing.T) {
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
return IconFetchResponse{ContentLength: int64(len(document))}, nil
}))
_, err := cache.Load(context.Background(), request)
if !errors.Is(err, ErrIconResponseInvalid) {
t.Fatalf("Load() error = %v, want %v", err, ErrIconResponseInvalid)
}
})
t.Run("invalid content length", func(t *testing.T) {
body := newTrackingIconBody(document)
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
return IconFetchResponse{Body: body, ContentLength: -2}, nil
}))
_, err := cache.Load(context.Background(), request)
if !errors.Is(err, ErrIconResponseInvalid) {
t.Fatalf("Load() error = %v, want %v", err, ErrIconResponseInvalid)
}
if got := atomic.LoadInt64(&body.bytesRead); got != 0 {
t.Fatalf("body bytes read = %d, want 0", got)
}
if got := atomic.LoadInt32(&body.closed); got != 1 {
t.Fatalf("body close calls = %d, want 1", got)
}
})
}
type trackingIconBody struct {
reader *bytes.Reader
bytesRead int64
closed int32
}
func newTrackingIconBody(document []byte) *trackingIconBody {
return &trackingIconBody{reader: bytes.NewReader(document)}
}
func (body *trackingIconBody) Read(buffer []byte) (int, error) {
read, err := body.reader.Read(buffer)
atomic.AddInt64(&body.bytesRead, int64(read))
return read, err
}
func (body *trackingIconBody) Close() error {
atomic.AddInt32(&body.closed, 1)
return nil
}
func loadIconAsync(
cache *IconCache,
ctx context.Context,
request IconRequest,
) <-chan iconLoadOutcome {
outcome := make(chan iconLoadOutcome, 1)
go func() {
result, err := cache.Load(ctx, request)
outcome <- iconLoadOutcome{result: result, err: err}
}()
return outcome
}
func waitSignal[T any](t *testing.T, signal <-chan T, failure string) T {
t.Helper()
select {
case value := <-signal:
return value
case <-time.After(2 * time.Second):
t.Fatal(failure)
var zero T
return zero
}
}
func waitOutcome(t *testing.T, outcome <-chan iconLoadOutcome) iconLoadOutcome {
t.Helper()
return waitSignal(t, outcome, "icon load did not finish")
}
+256
View File
@@ -0,0 +1,256 @@
package catalog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"image"
"image/color"
"image/png"
"io"
"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,
) (IconFetchResponse, error) {
fetchCalls++
return iconResponse(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,
) (IconFetchResponse, error) {
return IconFetchResponse{}, 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,
) (IconFetchResponse, error) {
fetchCalls++
return iconResponse(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,
) (IconFetchResponse, error) {
return iconResponse(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,
) (IconFetchResponse, error) {
return iconResponse(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,
) (IconFetchResponse, error) {
repairs++
return iconResponse(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,
) (IconFetchResponse, error) {
return IconFetchResponse{}, 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 iconResponse(document []byte) IconFetchResponse {
return IconFetchResponse{
Body: io.NopCloser(bytes.NewReader(document)),
ContentLength: int64(len(document)),
}
}
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()
}
+179
View File
@@ -0,0 +1,179 @@
package catalog
import (
"context"
"errors"
"fmt"
"reflect"
"softbox.local/core/application"
)
var (
ErrIconDeliveryInvalid = errors.New("icon event delivery is invalid")
ErrIconEventPublish = errors.New("publish icon application event")
)
// IconLoader is the narrow cache contract used by background delivery.
type IconLoader interface {
Load(context.Context, IconRequest) (IconResult, error)
}
// IconLoaderFunc adapts a function to IconLoader.
type IconLoaderFunc func(context.Context, IconRequest) (IconResult, error)
func (function IconLoaderFunc) Load(
ctx context.Context,
request IconRequest,
) (IconResult, error) {
return function(ctx, request)
}
// IconEventPublisher queues application events for UI adapters.
type IconEventPublisher interface {
Publish(context.Context, application.Event) error
}
// IconEventPublisherFunc adapts a function to IconEventPublisher.
type IconEventPublisherFunc func(context.Context, application.Event) error
func (function IconEventPublisherFunc) Publish(
ctx context.Context,
event application.Event,
) error {
return function(ctx, event)
}
// IconEventDelivery loads and decodes an icon in a caller-owned background task.
// It never creates goroutines and never imports or mutates Gio state.
type IconEventDelivery struct {
Loader IconLoader
Publisher IconEventPublisher
}
// LoadAndPublish emits one ready or failed application event.
// Cancellation ends silently so an obsolete request cannot publish a stale failure.
func (delivery IconEventDelivery) LoadAndPublish(
ctx context.Context,
identity application.IconEventIdentity,
) error {
validated, err := application.NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, err)
}
if isNilIconDeliveryDependency(delivery.Loader) ||
isNilIconDeliveryDependency(delivery.Publisher) {
return fmt.Errorf(
"%w: loader and publisher are required",
ErrIconDeliveryInvalid,
)
}
if err := ctx.Err(); err != nil {
return err
}
result, loadErr := delivery.Loader.Load(ctx, IconRequest{
Reference: validated.Reference,
DPI: validated.DPI,
})
if loadErr != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if isIconDeliveryCancellation(loadErr) {
return loadErr
}
return delivery.publishFailure(ctx, validated, loadErr)
}
if err := ctx.Err(); err != nil {
return err
}
icon, decodeErr := DecodeIcon(result.Bytes)
if decodeErr != nil {
return delivery.publishFailure(ctx, validated, decodeErr)
}
if err := ctx.Err(); err != nil {
return err
}
ready, eventErr := application.NewIconReadyEvent(validated, icon)
if eventErr != nil {
return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr)
}
if publishErr := delivery.Publisher.Publish(ctx, ready); publishErr != nil {
if isIconDeliveryCancellation(publishErr) {
return publishErr
}
return errors.Join(ErrIconEventPublish, publishErr)
}
return result.Warning
}
func (delivery IconEventDelivery) publishFailure(
ctx context.Context,
identity application.IconEventIdentity,
cause error,
) error {
if err := ctx.Err(); err != nil {
return err
}
failed, eventErr := application.NewIconFailedEvent(
identity,
classifyIconFailure(cause),
)
if eventErr != nil {
return errors.Join(
fmt.Errorf("load or decode icon: %w", cause),
fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr),
)
}
publishErr := delivery.Publisher.Publish(ctx, failed)
operationErr := fmt.Errorf("load or decode icon: %w", cause)
if publishErr != nil {
if isIconDeliveryCancellation(publishErr) {
return publishErr
}
return errors.Join(operationErr, ErrIconEventPublish, publishErr)
}
return operationErr
}
func classifyIconFailure(err error) application.IconFailureCode {
switch {
case errors.Is(err, ErrIconCacheUnsafe):
return application.IconFailureUnsafe
case errors.Is(err, ErrIconReferenceInvalid),
errors.Is(err, ErrIconDPIInvalid),
errors.Is(err, ErrIconHashMismatch),
errors.Is(err, ErrIconTooLarge),
errors.Is(err, ErrIconImageInvalid),
errors.Is(err, ErrIconResponseInvalid):
return application.IconFailureInvalid
default:
return application.IconFailureUnavailable
}
}
func isIconDeliveryCancellation(err error) bool {
return errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded)
}
func isNilIconDeliveryDependency(dependency any) bool {
if dependency == nil {
return true
}
value := reflect.ValueOf(dependency)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
+236
View File
@@ -0,0 +1,236 @@
package catalog
import (
"context"
"errors"
"strings"
"testing"
"softbox.local/core/application"
)
func TestIconEventDeliveryPublishesDecodedReadyEvent(t *testing.T) {
document := testPNG(t, 24, 24)
identity := iconEventIdentity(t, document, "request-ready")
var gotRequest IconRequest
var events []application.Event
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
_ context.Context,
request IconRequest,
) (IconResult, error) {
gotRequest = request
return IconResult{Bytes: document, Source: IconSourceMemory}, nil
}),
Publisher: IconEventPublisherFunc(func(
_ context.Context,
event application.Event,
) error {
events = append(events, event)
return nil
}),
}
if err := delivery.LoadAndPublish(context.Background(), identity); err != nil {
t.Fatalf("LoadAndPublish() error = %v", err)
}
if gotRequest.Reference != identity.Reference || gotRequest.DPI != identity.DPI {
t.Fatalf("loader request = %+v", gotRequest)
}
if len(events) != 1 {
t.Fatalf("published events = %d", len(events))
}
parsed, handled, err := application.ParseIconEvent(events[0])
if err != nil || !handled {
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, err)
}
if parsed.Type != application.EventIconReady || parsed.Identity != identity {
t.Fatalf("ready event = %+v", parsed)
}
if bounds := parsed.Image.Bounds(); bounds.Dx() != 24 || bounds.Dy() != 24 {
t.Fatalf("decoded bounds = %v", bounds)
}
}
func TestIconEventDeliveryClassifiesFailuresWithoutRawErrorPayload(t *testing.T) {
document := testPNG(t, 8, 8)
identity := iconEventIdentity(t, document, "request-failed")
tests := []struct {
name string
loadErr error
bytes []byte
wantCode application.IconFailureCode
}{
{name: "unsafe", loadErr: ErrIconCacheUnsafe, wantCode: application.IconFailureUnsafe},
{name: "invalid", loadErr: ErrIconHashMismatch, wantCode: application.IconFailureInvalid},
{
name: "unavailable",
loadErr: errors.New("GET https://secret.invalid/icon?token=hidden failed"),
wantCode: application.IconFailureUnavailable,
},
{name: "decode", bytes: []byte("not an image"), wantCode: application.IconFailureInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var published application.Event
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
return IconResult{Bytes: test.bytes}, test.loadErr
}),
Publisher: IconEventPublisherFunc(func(
_ context.Context,
event application.Event,
) error {
published = event
return nil
}),
}
err := delivery.LoadAndPublish(context.Background(), identity)
if err == nil {
t.Fatal("LoadAndPublish() unexpectedly succeeded")
}
parsed, handled, parseErr := application.ParseIconEvent(published)
if parseErr != nil || !handled {
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, parseErr)
}
if parsed.Type != application.EventIconFailed || parsed.ErrorCode != test.wantCode {
t.Fatalf("failed event = %+v", parsed)
}
payload := published.Payload.(application.IconFailedPayload)
if strings.Contains(string(payload.ErrorCode), "secret") ||
strings.Contains(string(payload.ErrorCode), "token") {
t.Fatalf("failure payload leaked raw error: %+v", payload)
}
})
}
}
func TestIconEventDeliveryReportsPublishFailureAndCacheWarning(t *testing.T) {
document := testPNG(t, 12, 12)
identity := iconEventIdentity(t, document, "request-publish")
publishErr := errors.New("event queue closed")
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
return IconResult{Bytes: document}, nil
}),
Publisher: IconEventPublisherFunc(func(
context.Context,
application.Event,
) error {
return publishErr
}),
}
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconEventPublish) || !errors.Is(err, publishErr) {
t.Fatalf("LoadAndPublish() error = %v", err)
}
warning := errors.New("disk store warning")
delivery.Loader = IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
return IconResult{Bytes: document, Warning: warning}, nil
})
delivery.Publisher = IconEventPublisherFunc(func(
context.Context,
application.Event,
) error {
return nil
})
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, warning) {
t.Fatalf("LoadAndPublish() warning = %v", err)
}
}
func TestIconEventDeliveryCancellationPublishesNothing(t *testing.T) {
document := testPNG(t, 10, 10)
identity := iconEventIdentity(t, document, "request-canceled")
ctx, cancel := context.WithCancel(context.Background())
cancel()
loaderCalled := false
publisherCalled := false
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
loaderCalled = true
return IconResult{}, nil
}),
Publisher: IconEventPublisherFunc(func(
context.Context,
application.Event,
) error {
publisherCalled = true
return nil
}),
}
if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) {
t.Fatalf("LoadAndPublish() error = %v", err)
}
if loaderCalled || publisherCalled {
t.Fatalf("canceled delivery called loader=%t publisher=%t", loaderCalled, publisherCalled)
}
ctx, cancel = context.WithCancel(context.Background())
loaderCalled = false
publisherCalled = false
delivery.Loader = IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
loaderCalled = true
cancel()
return IconResult{Bytes: document}, nil
})
if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) {
t.Fatalf("LoadAndPublish(cancel during load) error = %v", err)
}
if !loaderCalled || publisherCalled {
t.Fatalf(
"cancel-during-load called loader=%t publisher=%t",
loaderCalled,
publisherCalled,
)
}
}
func TestIconEventDeliveryRejectsIncompleteDependencies(t *testing.T) {
document := testPNG(t, 4, 4)
identity := iconEventIdentity(t, document, "request-invalid")
tests := []IconEventDelivery{
{},
{Loader: IconLoaderFunc(nil), Publisher: IconEventPublisherFunc(nil)},
}
for _, delivery := range tests {
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconDeliveryInvalid) {
t.Fatalf("LoadAndPublish() error = %v", err)
}
}
}
func iconEventIdentity(
t *testing.T,
document []byte,
requestID string,
) application.IconEventIdentity {
t.Helper()
request := iconRequest(document, 96)
identity, err := application.NewIconEventIdentity(
requestID,
"app-one",
request.Reference,
request.DPI,
)
if err != nil {
t.Fatal(err)
}
return identity
}
+73
View File
@@ -0,0 +1,73 @@
package catalog
import "container/list"
type iconMemoryEntry struct {
key string
document []byte
}
type iconMemoryCache struct {
maxBytes int64
maxEntries int
usedBytes int64
entries map[string]*list.Element
order list.List
}
func newIconMemoryCache(maxBytes int64, maxEntries int) *iconMemoryCache {
return &iconMemoryCache{
maxBytes: maxBytes,
maxEntries: maxEntries,
entries: make(map[string]*list.Element),
}
}
func (memory *iconMemoryCache) get(key string) ([]byte, bool) {
element, exists := memory.entries[key]
if !exists {
return nil, false
}
memory.order.MoveToFront(element)
entry := element.Value.(*iconMemoryEntry)
return append([]byte(nil), entry.document...), true
}
func (memory *iconMemoryCache) put(key string, document []byte) {
if memory.maxBytes <= 0 || memory.maxEntries <= 0 {
return
}
stored := append([]byte(nil), document...)
if element, exists := memory.entries[key]; exists {
entry := element.Value.(*iconMemoryEntry)
memory.usedBytes -= int64(len(entry.document))
entry.document = stored
memory.usedBytes += int64(len(stored))
memory.order.MoveToFront(element)
} else {
entry := &iconMemoryEntry{key: key, document: stored}
element := memory.order.PushFront(entry)
memory.entries[key] = element
memory.usedBytes += int64(len(stored))
}
for memory.overLimit() {
memory.removeOldest()
}
}
func (memory *iconMemoryCache) overLimit() bool {
return memory.usedBytes > memory.maxBytes ||
len(memory.entries) > memory.maxEntries
}
func (memory *iconMemoryCache) removeOldest() {
element := memory.order.Back()
if element == nil {
return
}
entry := element.Value.(*iconMemoryEntry)
delete(memory.entries, entry.key)
memory.usedBytes -= int64(len(entry.document))
memory.order.Remove(element)
}
+124
View File
@@ -0,0 +1,124 @@
package catalog
import (
"context"
"sync/atomic"
"testing"
)
func TestIconMemoryCacheEvictsOldestEntryAndPromotesHits(t *testing.T) {
memory := newIconMemoryCache(64, 2)
memory.put("a", []byte{1, 2})
memory.put("b", []byte{3, 4})
if _, exists := memory.get("a"); !exists {
t.Fatal("expected a memory hit")
}
memory.put("c", []byte{5, 6})
if _, exists := memory.get("b"); exists {
t.Fatal("least-recently-used entry b was retained")
}
if _, exists := memory.get("a"); !exists {
t.Fatal("promoted entry a was evicted")
}
if _, exists := memory.get("c"); !exists {
t.Fatal("new entry c was evicted")
}
if memory.usedBytes != 4 || len(memory.entries) != 2 || memory.order.Len() != 2 {
t.Fatalf(
"memory state = bytes:%d entries:%d order:%d",
memory.usedBytes,
len(memory.entries),
memory.order.Len(),
)
}
}
func TestIconMemoryCacheTracksReplacementAndByteLimit(t *testing.T) {
memory := newIconMemoryCache(5, 10)
memory.put("a", []byte{1, 2, 3})
memory.put("b", []byte{4, 5})
memory.put("a", []byte{6, 7, 8, 9})
if _, exists := memory.get("b"); exists {
t.Fatal("byte limit did not evict the oldest entry")
}
document, exists := memory.get("a")
if !exists || len(document) != 4 {
t.Fatalf("replacement = %v, exists=%v", document, exists)
}
document[0] = 0xff
again, _ := memory.get("a")
if again[0] == 0xff {
t.Fatal("memory hit exposed the stored backing array")
}
if memory.usedBytes != 4 || len(memory.entries) != 1 {
t.Fatalf("memory state = bytes:%d entries:%d", memory.usedBytes, len(memory.entries))
}
}
func TestIconCacheLRUEvictionReloadsVerifiedDisk(t *testing.T) {
documents := [][]byte{
testPNG(t, 22, 16),
testPNG(t, 23, 16),
testPNG(t, 24, 16),
}
requests := make([]IconRequest, len(documents))
documentByReference := make(map[string][]byte, len(documents))
for index, document := range documents {
requests[index] = iconRequest(document, 96)
documentByReference[requests[index].Reference] = document
}
var fetchCalls int32
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
_ context.Context,
request IconRequest,
) (IconFetchResponse, error) {
atomic.AddInt32(&fetchCalls, 1)
return iconResponse(documentByReference[request.Reference]), nil
}))
cache.memory.maxEntries = 2
for _, request := range requests[:2] {
if _, err := cache.Load(context.Background(), request); err != nil {
t.Fatalf("Load(seed) error = %v", err)
}
}
if result, err := cache.Load(context.Background(), requests[0]); err != nil || result.Source != IconSourceMemory {
t.Fatalf("promote result = %#v, error=%v", result, err)
}
if _, err := cache.Load(context.Background(), requests[2]); err != nil {
t.Fatalf("Load(evict) error = %v", err)
}
result, err := cache.Load(context.Background(), requests[1])
if err != nil || result.Source != IconSourceDisk {
t.Fatalf("reload result = %#v, error=%v", result, err)
}
if got := atomic.LoadInt32(&fetchCalls); got != 3 {
t.Fatalf("fetch calls = %d, want 3", got)
}
}
func TestNewIconCacheUsesBoundedMemoryDefaults(t *testing.T) {
cache := NewIconCache(t.TempDir(), nil)
if cache.memory.maxBytes != DefaultIconMemoryBytes ||
cache.memory.maxEntries != DefaultIconMemoryEntries {
t.Fatalf(
"memory limits = %d bytes/%d entries",
cache.memory.maxBytes,
cache.memory.maxEntries,
)
}
}
func TestIconMemoryCacheCanBeDisabledWithNonPositiveLimits(t *testing.T) {
for _, memory := range []*iconMemoryCache{
newIconMemoryCache(0, 1),
newIconMemoryCache(1, 0),
} {
memory.put("disabled", []byte{1})
if len(memory.entries) != 0 || memory.usedBytes != 0 {
t.Fatalf("disabled memory retained state: %#v", memory)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package catalog
import (
"context"
"errors"
"fmt"
)
var ErrNoValidCatalog = errors.New("no valid catalog available")
// Fetcher obtains a signed Catalog document from a remote or test source.
type Fetcher interface {
Fetch(context.Context) ([]byte, error)
}
// FetchFunc adapts a function to Fetcher.
type FetchFunc func(context.Context) ([]byte, error)
func (function FetchFunc) Fetch(ctx context.Context) ([]byte, error) {
return function(ctx)
}
// Cache stores the last verified signed document.
type Cache interface {
Load() ([]byte, error)
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
const (
SourceRemote LoadSource = "remote"
SourceCache LoadSource = "cache"
)
// LoadResult returns verified bytes and a non-fatal refresh/cache warning.
type LoadResult struct {
Document VerifiedDocument
Source LoadSource
Warning error
}
// LoadError preserves both the refresh and cache failure.
type LoadError struct {
Refresh error
Cache error
}
func (err *LoadError) Error() string {
return fmt.Sprintf("%s: refresh=%v; cache=%v", ErrNoValidCatalog, err.Refresh, err.Cache)
}
func (err *LoadError) Unwrap() error {
return ErrNoValidCatalog
}
// Loader verifies remote data before storing it and re-verifies cache fallback.
type Loader struct {
verifier Verifier
fetcher Fetcher
cache Cache
validators []DocumentValidator
}
func NewLoader(
verifier Verifier,
fetcher Fetcher,
cache Cache,
validators ...DocumentValidator,
) *Loader {
return &Loader{
verifier: verifier,
fetcher: fetcher,
cache: cache,
validators: append([]DocumentValidator(nil), validators...),
}
}
// Load prefers a verified remote document and falls back to verified cache.
func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
remoteBytes, refreshErr := loader.fetcher.Fetch(ctx)
if refreshErr == nil {
verified, verifyErr := loader.verifier.Verify(remoteBytes)
if verifyErr == 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
}
cachedBytes, cacheErr := loader.cache.Load()
if cacheErr == nil {
var verified VerifiedDocument
verified, cacheErr = loader.verifier.Verify(cachedBytes)
if cacheErr == nil {
cacheErr = loader.validate(verified)
if cacheErr == nil {
return LoadResult{
Document: verified,
Source: SourceCache,
Warning: refreshErr,
}, nil
}
}
}
return LoadResult{}, &LoadError{
Refresh: refreshErr,
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
}
+148
View File
@@ -0,0 +1,148 @@
package catalog
import (
"context"
"errors"
"testing"
)
func TestLoaderUsesVerifiedRemoteAndStoresCache(t *testing.T) {
verifier, validDocument, _ := loaderTestDocuments(t)
cache := &memoryCache{}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return validDocument, nil
}), cache)
result, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceRemote {
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
}
if string(cache.document) != string(validDocument) {
t.Fatal("verified remote document was not stored")
}
}
func TestLoaderFallsBackToVerifiedCache(t *testing.T) {
verifier, validDocument, _ := loaderTestDocuments(t)
offline := errors.New("offline")
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return nil, offline
}), cache)
result, err := loader.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, offline) {
t.Fatalf("Warning = %v, want %v", result.Warning, offline)
}
}
func TestLoaderRejectsRemoteWithoutOverwritingCache(t *testing.T) {
verifier, validDocument, tamperedDocument := loaderTestDocuments(t)
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return tamperedDocument, nil
}), cache)
result, err := loader.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, ErrSignatureInvalid) {
t.Fatalf("Warning = %v, want %v", result.Warning, ErrSignatureInvalid)
}
if cache.storeCalls != 0 {
t.Fatalf("Store() called %d times for invalid remote", cache.storeCalls)
}
if string(cache.document) != string(validDocument) {
t.Fatal("invalid remote changed cached document")
}
}
func TestLoaderRejectsInvalidCache(t *testing.T) {
verifier, _, tamperedDocument := loaderTestDocuments(t)
offline := errors.New("offline")
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return nil, offline
}), &memoryCache{document: tamperedDocument})
_, err := loader.Load(context.Background())
if !errors.Is(err, ErrNoValidCatalog) {
t.Fatalf("Load() error = %v, want %v", err, ErrNoValidCatalog)
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("Load() error type = %T, want *LoadError", err)
}
if !errors.Is(loadErr.Cache, ErrSignatureInvalid) {
t.Fatalf("cache error = %v, want %v", loadErr.Cache, ErrSignatureInvalid)
}
}
func TestLoaderReturnsCacheStoreWarning(t *testing.T) {
verifier, validDocument, _ := loaderTestDocuments(t)
storeFailure := errors.New("disk full")
cache := &memoryCache{storeErr: storeFailure}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return validDocument, nil
}), cache)
result, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceRemote {
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
}
if !errors.Is(result.Warning, storeFailure) {
t.Fatalf("Warning = %v, want %v", result.Warning, storeFailure)
}
}
type memoryCache struct {
document []byte
loadErr error
storeErr error
storeCalls int
}
func (cache *memoryCache) Load() ([]byte, error) {
if cache.loadErr != nil {
return nil, cache.loadErr
}
return append([]byte(nil), cache.document...), nil
}
func (cache *memoryCache) Store(document []byte) error {
cache.storeCalls++
if cache.storeErr != nil {
return cache.storeErr
}
cache.document = append([]byte(nil), document...)
return nil
}
func loaderTestDocuments(t *testing.T) (Verifier, []byte, []byte) {
t.Helper()
publicKey, privateKey := catalogTestKey()
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
validDocument, signature := signCatalogPayload(t, validPayload, privateKey)
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
tamperedDocument := attachCatalogSignature(t, tamperedPayload, signature)
return verifier, validDocument, tamperedDocument
}
+113
View File
@@ -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
}
+293
View File
@@ -0,0 +1,293 @@
package catalog
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"time"
"softbox.local/core/domain"
"softbox.local/core/internal/safepath"
)
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-]+$`)
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 _, err := domain.ParseSemVer(manifest.MinBoxVersion); err != nil {
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 _, err := domain.ParseSemVer(app.Version); err != nil {
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 err := safepath.ValidateRelative(app.EntryEXE); err != nil {
return invalidField(
"entry_exe",
"must be a safe Windows relative path: %v",
err,
)
}
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 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
}
+190
View File
@@ -0,0 +1,190 @@
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 TestParserRejectsUnsafeWindowsEntrypoints(t *testing.T) {
for _, entrypoint := range []string{
".. /escape.exe",
"bin./App.exe",
"App.exe.",
"App.exe ",
" App.exe",
"NUL",
"con.txt",
"bad?.exe",
`bin\App.exe`,
} {
t.Run(entrypoint, func(t *testing.T) {
manifest := validManifestForTest()
manifest.Apps[0].EntryEXE = entrypoint
_, err := parseSignedManifestForTest(t, manifest, ChannelModern)
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("Parse() error = %v, want %v", err, ErrInvalidManifest)
}
})
}
}
func TestParserAcceptsUnicodeNestedEntrypoint(t *testing.T) {
manifest := validManifestForTest()
manifest.Apps[0].EntryEXE = "工具/解析器.exe"
parsed, err := parseSignedManifestForTest(t, manifest, ChannelModern)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if parsed.Apps[0].EntryEXE != manifest.Apps[0].EntryEXE {
t.Fatalf(
"EntryEXE = %q, want %q",
parsed.Apps[0].EntryEXE,
manifest.Apps[0].EntryEXE,
)
}
}
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)
}
+33
View File
@@ -0,0 +1,33 @@
package catalog
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestProtocolSchemasAreValidJSONObjects(t *testing.T) {
for _, name := range []string{
"manifest.schema.json",
"app.schema.json",
"installed-app.schema.json",
} {
t.Run(name, func(t *testing.T) {
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
if err != nil {
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"])
}
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package catalog
import (
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
)
var (
ErrInvalidDocument = errors.New("invalid catalog document")
ErrDuplicateField = errors.New("duplicate catalog field")
ErrUnsupportedNumber = errors.New("unsupported catalog number")
ErrSignatureMissing = errors.New("catalog signature missing")
ErrSignatureInvalid = errors.New("catalog signature invalid")
ErrPublicKeyInvalid = errors.New("catalog public key invalid")
)
// VerifiedDocument contains the original signed document and its signing bytes.
type VerifiedDocument struct {
Bytes []byte
SignedPayload []byte
}
// Verifier validates signed Catalog JSON documents with one Ed25519 public key.
type Verifier struct {
publicKey ed25519.PublicKey
}
// NewVerifier copies and validates the public key.
func NewVerifier(publicKey []byte) (Verifier, error) {
if len(publicKey) != ed25519.PublicKeySize {
return Verifier{}, fmt.Errorf(
"%w: got %d bytes, want %d",
ErrPublicKeyInvalid,
len(publicKey),
ed25519.PublicKeySize,
)
}
keyCopy := append(ed25519.PublicKey(nil), publicKey...)
return Verifier{publicKey: keyCopy}, nil
}
// Verify rejects ambiguous JSON and validates the top-level signature.
func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) {
rootValue, err := parseRestrictedJSON(document)
if err != nil {
return VerifiedDocument{}, err
}
root, ok := rootValue.(map[string]any)
if !ok {
return VerifiedDocument{}, fmt.Errorf("%w: root must be an object", ErrInvalidDocument)
}
signatureValue, exists := root["signature"]
if !exists {
return VerifiedDocument{}, ErrSignatureMissing
}
signatureText, ok := signatureValue.(string)
if !ok {
return VerifiedDocument{}, fmt.Errorf("%w: signature must be a string", ErrSignatureInvalid)
}
delete(root, "signature")
signedPayload, err := canonicalJSON(root)
if err != nil {
return VerifiedDocument{}, err
}
signature, err := base64.StdEncoding.Strict().DecodeString(signatureText)
if err != nil {
return VerifiedDocument{}, fmt.Errorf("%w: base64: %v", ErrSignatureInvalid, err)
}
if len(signature) != ed25519.SignatureSize {
return VerifiedDocument{}, fmt.Errorf(
"%w: got %d signature bytes, want %d",
ErrSignatureInvalid,
len(signature),
ed25519.SignatureSize,
)
}
if !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
return VerifiedDocument{}, ErrSignatureInvalid
}
return VerifiedDocument{
Bytes: append([]byte(nil), document...),
SignedPayload: append([]byte(nil), signedPayload...),
}, nil
}
+136
View File
@@ -0,0 +1,136 @@
package catalog
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
func TestVerifierFixtures(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, validSignature := signCatalogPayload(t, validPayload, privateKey)
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
tamperedDocument := attachCatalogSignature(t, tamperedPayload, validSignature)
forgedDocument := readCatalogFixture(t, "manifest-forged.json")
tests := []struct {
name string
document []byte
wantErr error
}{
{name: "valid", document: validDocument},
{name: "tampered", document: tamperedDocument, wantErr: ErrSignatureInvalid},
{name: "forged", document: forgedDocument, wantErr: ErrSignatureInvalid},
{
name: "duplicate field",
document: []byte(`{"channel":"modern","channel":"win7","signature":"x"}`),
wantErr: ErrDuplicateField,
},
{
name: "fractional number",
document: []byte(`{"schema_version":1.5,"signature":"x"}`),
wantErr: ErrUnsupportedNumber,
},
{
name: "exponent number",
document: []byte(`{"schema_version":1e2,"signature":"x"}`),
wantErr: ErrUnsupportedNumber,
},
{
name: "missing signature",
document: validPayload,
wantErr: ErrSignatureMissing,
},
{
name: "trailing value",
document: append(append([]byte(nil), validDocument...), []byte(` {}`)...),
wantErr: ErrInvalidDocument,
},
{
name: "invalid UTF-8",
document: []byte{0xff, 0xfe},
wantErr: ErrInvalidDocument,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
verified, err := verifier.Verify(test.document)
if test.wantErr == nil {
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
if len(verified.SignedPayload) == 0 {
t.Fatal("Verify() returned empty signed payload")
}
return
}
if !errors.Is(err, test.wantErr) {
t.Fatalf("Verify() error = %v, want %v", err, test.wantErr)
}
})
}
}
func catalogTestKey() (ed25519.PublicKey, ed25519.PrivateKey) {
seed := sha256.Sum256([]byte("SoftBox catalog verifier test key - never use in production"))
privateKey := ed25519.NewKeyFromSeed(seed[:])
return privateKey.Public().(ed25519.PublicKey), privateKey
}
func readCatalogFixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("..", "..", "testdata", "catalog", name)
document, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return document
}
func signCatalogPayload(t *testing.T, payload []byte, privateKey ed25519.PrivateKey) ([]byte, string) {
t.Helper()
value, err := parseRestrictedJSON(payload)
if err != nil {
t.Fatalf("parse payload: %v", err)
}
root, ok := value.(map[string]any)
if !ok {
t.Fatal("payload root is not an object")
}
canonical, err := canonicalJSON(root)
if err != nil {
t.Fatalf("canonicalize payload: %v", err)
}
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
return attachCatalogSignature(t, payload, signature), signature
}
func attachCatalogSignature(t *testing.T, payload []byte, signature string) []byte {
t.Helper()
value, err := parseRestrictedJSON(payload)
if err != nil {
t.Fatalf("parse payload: %v", err)
}
root, ok := value.(map[string]any)
if !ok {
t.Fatal("payload root is not an object")
}
root["signature"] = signature
document, err := json.MarshalIndent(root, "", " ")
if err != nil {
t.Fatalf("encode signed document: %v", err)
}
return document
}
+2
View File
@@ -0,0 +1,2 @@
// Package domain contains SoftBox business models and rules.
package domain
+212
View File
@@ -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
}
}
+79
View File
@@ -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)
}
})
}
}
+134
View File
@@ -0,0 +1,134 @@
package domain
import (
"errors"
"fmt"
)
// AppStatus describes the user-visible lifecycle state of one catalog app.
type AppStatus string
const (
StatusNotInstalled AppStatus = "not_installed"
StatusQueued AppStatus = "queued"
StatusDownloading AppStatus = "downloading"
StatusVerifying AppStatus = "verifying"
StatusExtracting AppStatus = "extracting"
StatusInstalling AppStatus = "installing"
StatusInstalled AppStatus = "installed"
StatusUpdateAvailable AppStatus = "update_available"
StatusRunning AppStatus = "running"
StatusFailed AppStatus = "failed"
StatusRollbackPending AppStatus = "rollback_pending"
StatusIncompatible AppStatus = "incompatible"
)
var (
// ErrInvalidStatus indicates that a transition contains an unknown status.
ErrInvalidStatus = errors.New("invalid app status")
// ErrInvalidTransition indicates that two valid states cannot transition directly.
ErrInvalidTransition = errors.New("invalid app status transition")
)
var validStatuses = map[AppStatus]struct{}{
StatusNotInstalled: {},
StatusQueued: {},
StatusDownloading: {},
StatusVerifying: {},
StatusExtracting: {},
StatusInstalling: {},
StatusInstalled: {},
StatusUpdateAvailable: {},
StatusRunning: {},
StatusFailed: {},
StatusRollbackPending: {},
StatusIncompatible: {},
}
type statusTransition struct {
from AppStatus
to AppStatus
}
var allowedTransitions = map[statusTransition]struct{}{
{StatusNotInstalled, StatusQueued}: {},
{StatusNotInstalled, StatusIncompatible}: {},
{StatusQueued, StatusDownloading}: {},
{StatusQueued, StatusNotInstalled}: {},
{StatusQueued, StatusInstalled}: {},
{StatusQueued, StatusUpdateAvailable}: {},
{StatusQueued, StatusFailed}: {},
{StatusDownloading, StatusQueued}: {},
{StatusDownloading, StatusVerifying}: {},
{StatusDownloading, StatusNotInstalled}: {},
{StatusDownloading, StatusInstalled}: {},
{StatusDownloading, StatusUpdateAvailable}: {},
{StatusDownloading, StatusFailed}: {},
{StatusVerifying, StatusExtracting}: {},
{StatusVerifying, StatusFailed}: {},
{StatusExtracting, StatusInstalling}: {},
{StatusExtracting, StatusFailed}: {},
{StatusInstalling, StatusInstalled}: {},
{StatusInstalling, StatusRollbackPending}: {},
{StatusInstalling, StatusFailed}: {},
{StatusInstalled, StatusUpdateAvailable}: {},
{StatusInstalled, StatusRunning}: {},
{StatusInstalled, StatusQueued}: {},
{StatusInstalled, StatusIncompatible}: {},
{StatusUpdateAvailable, StatusQueued}: {},
{StatusUpdateAvailable, StatusRunning}: {},
{StatusUpdateAvailable, StatusInstalled}: {},
{StatusUpdateAvailable, StatusIncompatible}: {},
{StatusRunning, StatusInstalled}: {},
{StatusRunning, StatusUpdateAvailable}: {},
{StatusFailed, StatusQueued}: {},
{StatusFailed, StatusNotInstalled}: {},
{StatusFailed, StatusInstalled}: {},
{StatusFailed, StatusUpdateAvailable}: {},
{StatusFailed, StatusRollbackPending}: {},
{StatusRollbackPending, StatusInstalled}: {},
{StatusRollbackPending, StatusFailed}: {},
{StatusIncompatible, StatusNotInstalled}: {},
{StatusIncompatible, StatusInstalled}: {},
{StatusIncompatible, StatusUpdateAvailable}: {},
}
// Valid reports whether status is one of the documented domain states.
func (status AppStatus) Valid() bool {
_, ok := validStatuses[status]
return ok
}
// CanTransition reports whether from can move directly to to.
func CanTransition(from, to AppStatus) bool {
return ValidateTransition(from, to) == nil
}
// ValidateTransition verifies a direct state change.
func ValidateTransition(from, to AppStatus) error {
if !from.Valid() {
return fmt.Errorf("%w: %q", ErrInvalidStatus, from)
}
if !to.Valid() {
return fmt.Errorf("%w: %q", ErrInvalidStatus, to)
}
if _, ok := allowedTransitions[statusTransition{from: from, to: to}]; !ok {
return &TransitionError{From: from, To: to}
}
return nil
}
// TransitionError describes a rejected direct state change.
type TransitionError struct {
From AppStatus
To AppStatus
}
func (err *TransitionError) Error() string {
return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To)
}
// Unwrap allows callers to use errors.Is with ErrInvalidTransition.
func (err *TransitionError) Unwrap() error {
return ErrInvalidTransition
}
+73
View File
@@ -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
}
}
+90
View File
@@ -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)
}
}
+77
View File
@@ -0,0 +1,77 @@
package domain
import (
"errors"
"testing"
)
func TestAppStatusValid(t *testing.T) {
statuses := []AppStatus{
StatusNotInstalled,
StatusQueued,
StatusDownloading,
StatusVerifying,
StatusExtracting,
StatusInstalling,
StatusInstalled,
StatusUpdateAvailable,
StatusRunning,
StatusFailed,
StatusRollbackPending,
StatusIncompatible,
}
for _, status := range statuses {
if !status.Valid() {
t.Errorf("status %q should be valid", status)
}
}
if AppStatus("unknown").Valid() {
t.Fatal("unknown status should be invalid")
}
}
func TestValidateTransition(t *testing.T) {
tests := []struct {
name string
from AppStatus
to AppStatus
want error
}{
{name: "queue install", from: StatusNotInstalled, to: StatusQueued},
{name: "start download", from: StatusQueued, to: StatusDownloading},
{name: "pause download", from: StatusDownloading, to: StatusQueued},
{name: "finish install", from: StatusInstalling, to: StatusInstalled},
{name: "start app", from: StatusInstalled, to: StatusRunning},
{name: "recover rollback", from: StatusRollbackPending, to: StatusInstalled},
{name: "skip install pipeline", from: StatusNotInstalled, to: StatusInstalled, want: ErrInvalidTransition},
{name: "download while running", from: StatusRunning, to: StatusDownloading, want: ErrInvalidTransition},
{name: "extract incompatible app", from: StatusIncompatible, to: StatusExtracting, want: ErrInvalidTransition},
{name: "same status", from: StatusInstalled, to: StatusInstalled, want: ErrInvalidTransition},
{name: "unknown source", from: AppStatus("unknown"), to: StatusQueued, want: ErrInvalidStatus},
{name: "unknown target", from: StatusQueued, to: AppStatus("unknown"), want: ErrInvalidStatus},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateTransition(test.from, test.to)
if test.want == nil {
if err != nil {
t.Fatalf("ValidateTransition(%q, %q) error = %v", test.from, test.to, err)
}
if !CanTransition(test.from, test.to) {
t.Fatalf("CanTransition(%q, %q) = false, want true", test.from, test.to)
}
return
}
if !errors.Is(err, test.want) {
t.Fatalf("ValidateTransition(%q, %q) error = %v, want %v", test.from, test.to, err, test.want)
}
if CanTransition(test.from, test.to) {
t.Fatalf("CanTransition(%q, %q) = true, want false", test.from, test.to)
}
})
}
}
+79
View File
@@ -0,0 +1,79 @@
package downloader
import (
"context"
"softbox.local/core/application"
)
// ApplicationPublisher is implemented by application.Runtime.
type ApplicationPublisher interface {
Publish(context.Context, application.Event) error
}
// ApplicationObserver maps downloader events to the documented application
// event envelope and concrete payloads.
type ApplicationObserver struct {
Publisher ApplicationPublisher
}
// PublishDownload implements Observer.
func (observer ApplicationObserver) PublishDownload(
ctx context.Context,
event Event,
) error {
if observer.Publisher == nil {
return nil
}
envelope := application.Event{
RequestID: event.RequestID,
AppID: event.AppID,
}
switch event.Type {
case EventStarted:
envelope.Type = application.EventDownloadStarted
envelope.Payload = application.DownloadStartedPayload{
Attempt: event.Attempt,
Done: event.Done,
TotalKnown: event.TotalKnown,
Total: event.Total,
}
case EventProgress:
envelope.Type = application.EventDownloadProgress
envelope.Payload = application.DownloadProgressPayload{
Attempt: event.Attempt,
Done: event.Done,
TotalKnown: event.TotalKnown,
Total: event.Total,
SpeedBytesSec: event.SpeedBytesSec,
}
case EventPaused:
envelope.Type = application.EventDownloadPaused
envelope.Payload = application.DownloadPausedPayload{
Attempt: event.Attempt,
Done: event.Done,
}
case EventCompleted:
envelope.Type = application.EventDownloadCompleted
envelope.Payload = application.DownloadCompletedPayload{
Attempt: event.Attempt,
Done: event.Done,
Path: event.CompletedPath,
}
case EventFailed:
envelope.Type = application.EventDownloadFailed
envelope.Payload = application.DownloadFailedPayload{
Attempt: event.Attempt,
Done: event.Done,
ErrorCode: event.ErrorCode,
}
case EventCanceled:
envelope.Type = application.EventDownloadCanceled
envelope.Payload = application.DownloadCanceledPayload{
Attempt: event.Attempt,
}
default:
return nil
}
return observer.Publisher.Publish(ctx, envelope)
}
@@ -0,0 +1,102 @@
package downloader
import (
"context"
"reflect"
"testing"
"softbox.local/core/application"
)
func TestApplicationObserverUsesConcretePayloads(t *testing.T) {
publisher := &recordingApplicationPublisher{}
observer := ApplicationObserver{Publisher: publisher}
tests := []struct {
event Event
wantType application.EventType
wantPayload any
}{
{
event: Event{
Type: EventStarted, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 2, TotalKnown: true, Total: 10,
},
wantType: application.EventDownloadStarted,
wantPayload: application.DownloadStartedPayload{
Attempt: 1, Done: 2, TotalKnown: true, Total: 10,
},
},
{
event: Event{
Type: EventProgress, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 4, TotalKnown: true, Total: 10, SpeedBytesSec: 2,
},
wantType: application.EventDownloadProgress,
wantPayload: application.DownloadProgressPayload{
Attempt: 1, Done: 4, TotalKnown: true, Total: 10, SpeedBytesSec: 2,
},
},
{
event: Event{
Type: EventPaused, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 4,
},
wantType: application.EventDownloadPaused,
wantPayload: application.DownloadPausedPayload{Attempt: 1, Done: 4},
},
{
event: Event{
Type: EventCompleted, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 10, CompletedPath: "download",
},
wantType: application.EventDownloadCompleted,
wantPayload: application.DownloadCompletedPayload{
Attempt: 1, Done: 10, Path: "download",
},
},
{
event: Event{
Type: EventFailed, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 4, ErrorCode: "http_status",
},
wantType: application.EventDownloadFailed,
wantPayload: application.DownloadFailedPayload{
Attempt: 1, Done: 4, ErrorCode: "http_status",
},
},
{
event: Event{
Type: EventCanceled, RequestID: "request-1", AppID: "app-1",
Attempt: 1,
},
wantType: application.EventDownloadCanceled,
wantPayload: application.DownloadCanceledPayload{Attempt: 1},
},
}
for _, test := range tests {
if err := observer.PublishDownload(context.Background(), test.event); err != nil {
t.Fatalf("PublishDownload(%s) error = %v", test.event.Type, err)
}
got := publisher.events[len(publisher.events)-1]
if got.Type != test.wantType ||
got.RequestID != test.event.RequestID ||
got.AppID != test.event.AppID {
t.Fatalf("event = %#v", got)
}
if !reflect.DeepEqual(got.Payload, test.wantPayload) {
t.Fatalf("payload = %#v, want %#v", got.Payload, test.wantPayload)
}
}
}
type recordingApplicationPublisher struct {
events []application.Event
}
func (publisher *recordingApplicationPublisher) Publish(
_ context.Context,
event application.Event,
) error {
publisher.events = append(publisher.events, event)
return nil
}
+6
View File
@@ -0,0 +1,6 @@
// Package downloader provides resumable byte-transfer queues.
//
// A completed download is still untrusted input. Callers must verify the
// signed Catalog identity, exact size, SHA-256 and package signature before
// handing the file to the installer.
package downloader
+43
View File
@@ -0,0 +1,43 @@
package downloader
import "context"
// EventType identifies an observer event emitted by Queue.
type EventType string
const (
EventStarted EventType = "started"
EventProgress EventType = "progress"
EventPaused EventType = "paused"
EventCompleted EventType = "completed"
EventFailed EventType = "failed"
EventCanceled EventType = "canceled"
)
// Event reports one generation of a download task. Done is monotonic within a
// RequestID+Attempt pair. A new Attempt permits a reset to zero when a server
// ignores Range or an entity cannot be safely resumed.
type Event struct {
Type EventType
RequestID string
AppID string
Attempt uint64
Done int64
TotalKnown bool
Total int64
SpeedBytesSec int64
CompletedPath string
ErrorCode string
}
// Observer receives queue events. Implementations must honor context
// cancellation so pause/cancel can wait for the old generation to exit.
type Observer interface {
PublishDownload(context.Context, Event) error
}
type discardObserver struct{}
func (discardObserver) PublishDownload(context.Context, Event) error {
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package downloader
import (
"fmt"
"io"
"os"
)
func ensureTransferLayout(downloadsRoot, requestID string) (TaskPaths, error) {
paths, err := DeriveTaskPaths(downloadsRoot, requestID)
if err != nil {
return TaskPaths{}, err
}
if err := os.MkdirAll(paths.Root, 0o700); err != nil {
return TaskPaths{}, fmt.Errorf("create downloads root: %w", err)
}
for _, directory := range []string{paths.Root, paths.TasksDir, paths.FilesDir} {
if err := os.Mkdir(directory, 0o700); err != nil && !os.IsExist(err) {
return TaskPaths{}, fmt.Errorf("create download directory: %w", err)
}
info, err := os.Lstat(directory)
if err != nil {
return TaskPaths{}, fmt.Errorf("inspect download directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return TaskPaths{}, fmt.Errorf("%w: managed directory is unsafe", ErrTaskCorrupt)
}
}
return paths, nil
}
func regularFileSize(filePath string) (size int64, exists bool, err error) {
info, err := os.Lstat(filePath)
if os.IsNotExist(err) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("inspect download file: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return 0, false, fmt.Errorf("%w: managed file is not regular", ErrTaskCorrupt)
}
return info.Size(), true, nil
}
func regularFileExists(filePath string) (bool, error) {
_, exists, err := regularFileSize(filePath)
return exists, err
}
func truncatePart(filePath string) error {
file, _, err := openVerifiedRegular(filePath, os.O_RDWR)
if err != nil {
return err
}
if err := file.Truncate(0); err != nil {
file.Close()
return fmt.Errorf("truncate part: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
file.Close()
return fmt.Errorf("seek truncated part: %w", err)
}
if err := file.Sync(); err != nil {
file.Close()
return fmt.Errorf("sync truncated part: %w", err)
}
return file.Close()
}
func openPart(paths TaskPaths, offset int64, restart bool) (*os.File, error) {
if restart {
if _, exists, err := regularFileSize(paths.Part); err != nil {
return nil, err
} else if exists {
file, _, err := openVerifiedRegular(paths.Part, os.O_RDWR)
if err != nil {
return nil, err
}
if err := file.Truncate(0); err != nil {
file.Close()
return nil, fmt.Errorf("truncate restarted part: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
file.Close()
return nil, fmt.Errorf("seek restarted part: %w", err)
}
return file, nil
}
return os.OpenFile(paths.Part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
}
size, exists, err := regularFileSize(paths.Part)
if err != nil {
return nil, err
}
if !exists {
if offset != 0 {
return nil, fmt.Errorf("%w: missing part for non-zero offset", ErrTaskCorrupt)
}
return os.OpenFile(paths.Part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
}
if size != offset {
return nil, fmt.Errorf("%w: part size changed", ErrTaskCorrupt)
}
file, _, err := openVerifiedRegular(paths.Part, os.O_WRONLY)
if err != nil {
return nil, err
}
if _, err := file.Seek(offset, io.SeekStart); err != nil {
file.Close()
return nil, fmt.Errorf("seek part: %w", err)
}
return file, nil
}
func syncRegularFile(filePath string) error {
file, _, err := openVerifiedRegular(filePath, os.O_RDWR)
if err != nil {
return err
}
if err := file.Sync(); err != nil {
file.Close()
return err
}
return file.Close()
}
func activateCompleted(paths TaskPaths, expected os.FileInfo) error {
if exists, err := regularFileExists(paths.Completed); err != nil {
return err
} else if exists {
return fmt.Errorf("%w: completed path already exists", ErrTaskCorrupt)
}
if expected == nil {
part, partInfo, err := openVerifiedRegular(paths.Part, os.O_RDONLY)
if err != nil {
return err
}
if err := part.Close(); err != nil {
return fmt.Errorf("close part before activation: %w", err)
}
expected = partInfo
}
if err := verifyRegularIdentity(paths.Part, expected); err != nil {
return err
}
if err := os.Rename(paths.Part, paths.Completed); err != nil {
return fmt.Errorf("activate completed download: %w", err)
}
completedInfo, err := os.Lstat(paths.Completed)
if err != nil {
return fmt.Errorf("inspect activated download: %w", err)
}
if completedInfo.Mode()&os.ModeSymlink != 0 ||
!completedInfo.Mode().IsRegular() ||
!os.SameFile(expected, completedInfo) {
_ = removeExactRegularFile(paths.Completed)
return fmt.Errorf("%w: activated file identity changed", ErrTaskCorrupt)
}
return nil
}
func verifyRegularIdentity(filePath string, expected os.FileInfo) error {
current, err := os.Lstat(filePath)
if err != nil {
return fmt.Errorf("inspect managed file identity: %w", err)
}
if current.Mode()&os.ModeSymlink != 0 ||
!current.Mode().IsRegular() ||
!os.SameFile(expected, current) {
return fmt.Errorf("%w: managed file identity changed", ErrTaskCorrupt)
}
return nil
}
func removeExactRegularFile(filePath string) error {
info, err := os.Lstat(filePath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%w: refuse removal of non-regular file", ErrTaskCorrupt)
}
return os.Remove(filePath)
}
func syncAndClose(file *os.File) error {
if file == nil {
return nil
}
if err := file.Sync(); err != nil {
file.Close()
return err
}
return file.Close()
}
func openVerifiedRegular(
filePath string,
flags int,
) (*os.File, os.FileInfo, error) {
before, err := os.Lstat(filePath)
if err != nil {
return nil, nil, err
}
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
return nil, nil, fmt.Errorf("%w: managed file is not regular", ErrTaskCorrupt)
}
file, err := os.OpenFile(filePath, flags, 0o600)
if err != nil {
return nil, nil, fmt.Errorf("open managed file: %w", err)
}
opened, err := file.Stat()
if err != nil {
file.Close()
return nil, nil, fmt.Errorf("stat managed file: %w", err)
}
after, err := os.Lstat(filePath)
if err != nil {
file.Close()
return nil, nil, fmt.Errorf("restat managed file: %w", err)
}
if after.Mode()&os.ModeSymlink != 0 ||
!after.Mode().IsRegular() ||
!os.SameFile(before, opened) ||
!os.SameFile(opened, after) {
file.Close()
return nil, nil, fmt.Errorf("%w: managed file changed while opening", ErrTaskCorrupt)
}
return file, opened, nil
}
+45
View File
@@ -0,0 +1,45 @@
package downloader
import (
"errors"
"os"
"testing"
)
func TestActivateCompletedRejectsReplacementOfWrittenPart(t *testing.T) {
paths, err := ensureTransferLayout(t.TempDir(), "request-identity")
if err != nil {
t.Fatalf("ensureTransferLayout() error = %v", err)
}
if err := os.WriteFile(paths.Part, []byte("trusted"), 0o600); err != nil {
t.Fatalf("WriteFile(original) error = %v", err)
}
original, expected, err := openVerifiedRegular(paths.Part, os.O_RDONLY)
if err != nil {
t.Fatalf("openVerifiedRegular() error = %v", err)
}
if err := original.Close(); err != nil {
t.Fatalf("Close(original) error = %v", err)
}
if err := os.Remove(paths.Part); err != nil {
t.Fatalf("Remove(original) error = %v", err)
}
if err := os.WriteFile(paths.Part, []byte("replacement"), 0o600); err != nil {
t.Fatalf("WriteFile(replacement) error = %v", err)
}
err = activateCompleted(paths, expected)
if !errors.Is(err, ErrTaskCorrupt) {
t.Fatalf("activateCompleted() error = %v, want %v", err, ErrTaskCorrupt)
}
if _, err := os.Stat(paths.Completed); !os.IsNotExist(err) {
t.Fatalf("completed path exists after identity mismatch: %v", err)
}
document, err := os.ReadFile(paths.Part)
if err != nil {
t.Fatalf("ReadFile(replacement) error = %v", err)
}
if string(document) != "replacement" {
t.Fatalf("replacement bytes = %q", document)
}
}
+280
View File
@@ -0,0 +1,280 @@
package downloader
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
var (
ErrHTTPStatus = errors.New("download HTTP status is not successful")
ErrRangeMismatch = errors.New("download Content-Range does not match request")
ErrRangeEntityChanged = errors.New("download range entity validator changed")
ErrResponseEncoding = errors.New("download response encoding is not identity")
ErrInsecureRedirect = errors.New("download redirect target is not secure")
ErrTransferTooLarge = errors.New("download response exceeds byte limit")
ErrTransferIncomplete = errors.New("download response ended before expected length")
)
// OpenRequest describes one HTTP attempt.
type OpenRequest struct {
URL string
Offset int64
Validator EntityValidator
}
// OpenResponse owns Body until the caller closes it.
type OpenResponse struct {
Body io.ReadCloser
Restart bool
TotalKnown bool
Total int64
ResponseLengthKnown bool
ResponseLength int64
Validator EntityValidator
}
// Transport opens a remote byte stream at a requested offset.
type Transport interface {
Open(context.Context, OpenRequest) (OpenResponse, error)
}
// HTTPTransport implements strict HTTPS and Range semantics with net/http.
type HTTPTransport struct {
client *http.Client
}
// NewHTTPTransport creates a standard-library transport.
func NewHTTPTransport(client *http.Client) *HTTPTransport {
if client == nil {
client = http.DefaultClient
}
clientCopy := *client
previousRedirectCheck := client.CheckRedirect
clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if request.URL == nil || ValidateHTTPSURL(request.URL.String()) != nil {
return ErrInsecureRedirect
}
if previousRedirectCheck != nil {
return previousRedirectCheck(request, via)
}
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}
return &HTTPTransport{client: &clientCopy}
}
// Open starts a transfer. A 200 response to a Range request is returned as
// Restart=true so the queue truncates the old part before consuming bytes.
func (transport *HTTPTransport) Open(
ctx context.Context,
request OpenRequest,
) (OpenResponse, error) {
if err := ValidateHTTPSURL(request.URL); err != nil {
return OpenResponse{}, err
}
if request.Offset < 0 {
return OpenResponse{}, fmt.Errorf("%w: negative offset", ErrRangeMismatch)
}
if err := request.Validator.Validate(); err != nil {
return OpenResponse{}, fmt.Errorf("%w: invalid validator", ErrRangeMismatch)
}
if request.Offset > 0 && request.Validator.Empty() {
return OpenResponse{}, fmt.Errorf("%w: resume needs an entity validator", ErrRangeMismatch)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil)
if err != nil {
return OpenResponse{}, fmt.Errorf("create download request: %w", err)
}
httpRequest.Header.Set("Accept-Encoding", "identity")
if request.Offset > 0 {
httpRequest.Header.Set("Range", fmt.Sprintf("bytes=%d-", request.Offset))
httpRequest.Header.Set("If-Range", request.Validator.HeaderValue())
}
response, err := transport.client.Do(httpRequest)
if err != nil {
return OpenResponse{}, err
}
closeWithError := func(openErr error) (OpenResponse, error) {
_ = response.Body.Close()
return OpenResponse{}, openErr
}
if response.Request == nil || response.Request.URL == nil ||
ValidateHTTPSURL(response.Request.URL.String()) != nil {
return closeWithError(ErrInsecureRedirect)
}
encoding := strings.TrimSpace(response.Header.Get("Content-Encoding"))
if encoding != "" && !strings.EqualFold(encoding, "identity") {
return closeWithError(ErrResponseEncoding)
}
validator := responseValidator(response.Header)
switch response.StatusCode {
case http.StatusOK:
if response.Header.Get("Content-Range") != "" {
return closeWithError(fmt.Errorf("%w: 200 includes Content-Range", ErrRangeMismatch))
}
totalKnown := response.ContentLength >= 0
total := response.ContentLength
if !totalKnown {
total = 0
}
return OpenResponse{
Body: response.Body,
Restart: request.Offset > 0,
TotalKnown: totalKnown,
Total: total,
ResponseLengthKnown: totalKnown,
ResponseLength: total,
Validator: validator,
}, nil
case http.StatusPartialContent:
if request.Offset == 0 {
return closeWithError(fmt.Errorf("%w: unsolicited partial response", ErrRangeMismatch))
}
start, end, totalKnown, total, parseErr := parseContentRange(
response.Header.Get("Content-Range"),
)
if parseErr != nil || start != request.Offset {
return closeWithError(ErrRangeMismatch)
}
rangeLength := end - start + 1
if rangeLength <= 0 ||
(response.ContentLength >= 0 && response.ContentLength != rangeLength) {
return closeWithError(ErrRangeMismatch)
}
if !validatorsMatch(request.Validator, validator) {
return closeWithError(ErrRangeEntityChanged)
}
return OpenResponse{
Body: response.Body,
TotalKnown: totalKnown,
Total: total,
ResponseLengthKnown: true,
ResponseLength: rangeLength,
Validator: request.Validator,
}, nil
default:
return closeWithError(fmt.Errorf("%w: %d", ErrHTTPStatus, response.StatusCode))
}
}
func parseContentRange(value string) (
start int64,
end int64,
totalKnown bool,
total int64,
err error,
) {
if !strings.HasPrefix(value, "bytes ") {
return 0, 0, false, 0, ErrRangeMismatch
}
rangeAndTotal := strings.Split(strings.TrimPrefix(value, "bytes "), "/")
if len(rangeAndTotal) != 2 {
return 0, 0, false, 0, ErrRangeMismatch
}
bounds := strings.Split(rangeAndTotal[0], "-")
if len(bounds) != 2 {
return 0, 0, false, 0, ErrRangeMismatch
}
start, err = strconv.ParseInt(bounds[0], 10, 64)
if err != nil || start < 0 {
return 0, 0, false, 0, ErrRangeMismatch
}
end, err = strconv.ParseInt(bounds[1], 10, 64)
if err != nil || end < start || end == int64(^uint64(0)>>1) {
return 0, 0, false, 0, ErrRangeMismatch
}
if rangeAndTotal[1] == "*" {
return start, end, false, 0, nil
}
total, err = strconv.ParseInt(rangeAndTotal[1], 10, 64)
if err != nil || total <= end {
return 0, 0, false, 0, ErrRangeMismatch
}
return start, end, true, total, nil
}
// CopyOptions bounds one response copy and tags progress with the generation
// that owns it.
type CopyOptions struct {
MaxBytes int64
ExpectedBytesKnown bool
ExpectedBytes int64
Generation uint64
Progress func(CopyProgress) error
}
// CopyProgress reports bytes written during this response only.
type CopyProgress struct {
Generation uint64
Written int64
}
// CopyResponse copies a response with a hard byte cap. It reads at most
// MaxBytes+1 bytes so an oversized body is detected without unbounded IO.
func CopyResponse(
ctx context.Context,
dst io.Writer,
src io.Reader,
options CopyOptions,
) (int64, error) {
if options.MaxBytes < 0 || options.MaxBytes == int64(^uint64(0)>>1) {
return 0, fmt.Errorf("%w: invalid byte limit", ErrTransferTooLarge)
}
if options.ExpectedBytesKnown &&
(options.ExpectedBytes < 0 || options.ExpectedBytes > options.MaxBytes) {
return 0, fmt.Errorf("%w: invalid expected response length", ErrTransferIncomplete)
}
limited := &io.LimitedReader{R: src, N: options.MaxBytes + 1}
buffer := make([]byte, 32*1024)
var written int64
for {
if err := ctx.Err(); err != nil {
return written, err
}
readCount, readErr := limited.Read(buffer)
if readCount > 0 {
if int64(readCount) > options.MaxBytes-written {
return written, ErrTransferTooLarge
}
writeCount, writeErr := dst.Write(buffer[:readCount])
written += int64(writeCount)
if writeErr != nil {
return written, writeErr
}
if writeCount != readCount {
return written, io.ErrShortWrite
}
if options.Progress != nil {
if err := options.Progress(CopyProgress{
Generation: options.Generation,
Written: written,
}); err != nil {
return written, err
}
}
}
if readErr != nil {
if readErr != io.EOF {
return written, readErr
}
break
}
}
if options.ExpectedBytesKnown && written != options.ExpectedBytes {
return written, ErrTransferIncomplete
}
return written, nil
}
+295
View File
@@ -0,0 +1,295 @@
package downloader
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPTransportFreshAndResume(t *testing.T) {
content := []byte("abcdefghij")
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Header.Get("Accept-Encoding") != "identity" {
t.Errorf("Accept-Encoding = %q", request.Header.Get("Accept-Encoding"))
}
switch request.Header.Get("Range") {
case "":
writer.Header().Set("ETag", `"v1"`)
writer.Header().Set("Content-Length", "10")
_, _ = writer.Write(content)
case "bytes=4-":
if request.Header.Get("If-Range") != `"v1"` {
t.Errorf("If-Range = %q", request.Header.Get("If-Range"))
}
writer.Header().Set("ETag", `"v1"`)
writer.Header().Set("Content-Range", "bytes 4-9/10")
writer.Header().Set("Content-Length", "6")
writer.WriteHeader(http.StatusPartialContent)
_, _ = writer.Write(content[4:])
default:
t.Errorf("unexpected Range %q", request.Header.Get("Range"))
}
}))
defer server.Close()
transport := NewHTTPTransport(server.Client())
fresh, err := transport.Open(context.Background(), OpenRequest{URL: server.URL})
if err != nil {
t.Fatalf("Open(fresh) error = %v", err)
}
freshBytes, err := io.ReadAll(fresh.Body)
fresh.Body.Close()
if err != nil || !bytes.Equal(freshBytes, content) {
t.Fatalf("fresh bytes = %q, error=%v", freshBytes, err)
}
if fresh.Restart || !fresh.TotalKnown || fresh.Total != 10 ||
fresh.Validator.ETag != `"v1"` {
t.Fatalf("fresh response = %#v", fresh)
}
resumed, err := transport.Open(context.Background(), OpenRequest{
URL: server.URL,
Offset: 4,
Validator: EntityValidator{ETag: `"v1"`},
})
if err != nil {
t.Fatalf("Open(resume) error = %v", err)
}
resumedBytes, err := io.ReadAll(resumed.Body)
resumed.Body.Close()
if err != nil || !bytes.Equal(resumedBytes, content[4:]) {
t.Fatalf("resume bytes = %q, error=%v", resumedBytes, err)
}
if resumed.Restart || resumed.ResponseLength != 6 || resumed.Total != 10 {
t.Fatalf("resume response = %#v", resumed)
}
}
func TestHTTPTransportRestartsWhenRangeIgnored(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("ETag", `"v2"`)
writer.Header().Set("Content-Length", "4")
_, _ = writer.Write([]byte("new!"))
}))
defer server.Close()
response, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
OpenRequest{
URL: server.URL,
Offset: 2,
Validator: EntityValidator{ETag: `"v1"`},
},
)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
defer response.Body.Close()
if !response.Restart || response.Validator.ETag != `"v2"` {
t.Fatalf("response = %#v", response)
}
}
func TestHTTPTransportRejectsInvalidRangeResponses(t *testing.T) {
tests := []struct {
name string
contentRange string
etag string
want error
}{
{name: "wrong start", contentRange: "bytes 3-9/10", etag: `"v1"`, want: ErrRangeMismatch},
{name: "malformed", contentRange: "invalid", etag: `"v1"`, want: ErrRangeMismatch},
{name: "entity changed", contentRange: "bytes 4-9/10", etag: `"v2"`, want: ErrRangeEntityChanged},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Range", test.contentRange)
writer.Header().Set("Content-Length", "6")
writer.Header().Set("ETag", test.etag)
writer.WriteHeader(http.StatusPartialContent)
_, _ = writer.Write([]byte("efghij"))
}))
defer server.Close()
_, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
OpenRequest{
URL: server.URL,
Offset: 4,
Validator: EntityValidator{ETag: `"v1"`},
},
)
if !errors.Is(err, test.want) {
t.Fatalf("Open() error = %v, want %v", err, test.want)
}
})
}
}
func TestHTTPTransportRejectsNonSuccessStatuses(t *testing.T) {
for _, status := range []int{
http.StatusNotFound,
http.StatusRequestedRangeNotSatisfiable,
http.StatusInternalServerError,
} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.WriteHeader(status)
}))
defer server.Close()
_, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
OpenRequest{URL: server.URL},
)
if !errors.Is(err, ErrHTTPStatus) {
t.Fatalf("Open() error = %v, want %v", err, ErrHTTPStatus)
}
})
}
}
func TestHTTPTransportRejectsUnsafeSuccessMetadata(t *testing.T) {
tests := []struct {
name string
offset int64
headers map[string]string
status int
want error
}{
{
name: "encoded body",
status: http.StatusOK,
headers: map[string]string{
"Content-Encoding": "gzip",
},
want: ErrResponseEncoding,
},
{
name: "200 with content range",
status: http.StatusOK,
headers: map[string]string{
"Content-Range": "bytes 0-3/4",
},
want: ErrRangeMismatch,
},
{
name: "206 missing content range",
offset: 2,
status: http.StatusPartialContent,
headers: map[string]string{
"ETag": `"v1"`,
},
want: ErrRangeMismatch,
},
{
name: "206 content length mismatch",
offset: 2,
status: http.StatusPartialContent,
headers: map[string]string{
"Content-Range": "bytes 2-3/4",
"Content-Length": "1",
"ETag": `"v1"`,
},
want: ErrRangeMismatch,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
for name, value := range test.headers {
writer.Header().Set(name, value)
}
writer.WriteHeader(test.status)
_, _ = writer.Write([]byte("data"))
}))
defer server.Close()
request := OpenRequest{URL: server.URL, Offset: test.offset}
if test.offset > 0 {
request.Validator = EntityValidator{ETag: `"v1"`}
}
_, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
request,
)
if !errors.Is(err, test.want) {
t.Fatalf("Open() error = %v, want %v", err, test.want)
}
})
}
}
func TestHTTPTransportRejectsDowngradeRedirect(t *testing.T) {
insecure := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
_, _ = writer.Write([]byte("unsafe"))
}))
defer insecure.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
http.Redirect(writer, request, insecure.URL, http.StatusFound)
}))
defer secure.Close()
_, err := NewHTTPTransport(secure.Client()).Open(
context.Background(),
OpenRequest{URL: secure.URL},
)
if !errors.Is(err, ErrInsecureRedirect) {
t.Fatalf("Open() error = %v, want %v", err, ErrInsecureRedirect)
}
}
func TestCopyResponseBoundsAndCompleteness(t *testing.T) {
var destination bytes.Buffer
written, err := CopyResponse(
context.Background(),
&destination,
bytes.NewReader([]byte("1234")),
CopyOptions{MaxBytes: 4, ExpectedBytesKnown: true, ExpectedBytes: 4},
)
if err != nil || written != 4 || destination.String() != "1234" {
t.Fatalf("CopyResponse() = %d, %q, %v", written, destination.String(), err)
}
_, err = CopyResponse(
context.Background(),
io.Discard,
bytes.NewReader([]byte("12345")),
CopyOptions{MaxBytes: 4},
)
if !errors.Is(err, ErrTransferTooLarge) {
t.Fatalf("oversize error = %v, want %v", err, ErrTransferTooLarge)
}
_, err = CopyResponse(
context.Background(),
io.Discard,
bytes.NewReader([]byte("123")),
CopyOptions{MaxBytes: 4, ExpectedBytesKnown: true, ExpectedBytes: 4},
)
if !errors.Is(err, ErrTransferIncomplete) {
t.Fatalf("short error = %v, want %v", err, ErrTransferIncomplete)
}
}
+60
View File
@@ -0,0 +1,60 @@
package downloader
import (
"fmt"
"path/filepath"
"strings"
)
// TaskPaths are fixed local names derived only from RequestID.
type TaskPaths struct {
Root string
TasksDir string
FilesDir string
Metadata string
Backup string
Part string
Completed string
}
// DeriveTaskPaths derives all local paths without accepting remote filenames
// or Content-Disposition values.
func DeriveTaskPaths(downloadsRoot, requestID string) (TaskPaths, error) {
if downloadsRoot == "" {
return TaskPaths{}, fmt.Errorf("%w: empty downloads root", ErrInvalidTask)
}
if !ValidRequestID(requestID) {
return TaskPaths{}, fmt.Errorf("%w: invalid request_id", ErrInvalidTask)
}
absoluteRoot, err := filepath.Abs(downloadsRoot)
if err != nil {
return TaskPaths{}, fmt.Errorf("%w: resolve downloads root", ErrInvalidTask)
}
tasksDir := filepath.Join(absoluteRoot, "tasks")
filesDir := filepath.Join(absoluteRoot, "files")
paths := TaskPaths{
Root: absoluteRoot,
TasksDir: tasksDir,
FilesDir: filesDir,
Metadata: filepath.Join(tasksDir, requestID+".json"),
Backup: filepath.Join(tasksDir, requestID+".json.backup"),
Part: filepath.Join(filesDir, requestID+".part"),
Completed: filepath.Join(filesDir, requestID+".download"),
}
for _, candidate := range []string{
paths.TasksDir,
paths.FilesDir,
paths.Metadata,
paths.Backup,
paths.Part,
paths.Completed,
} {
relative, relativeErr := filepath.Rel(absoluteRoot, candidate)
if relativeErr != nil ||
relative == ".." ||
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return TaskPaths{}, fmt.Errorf("%w: derived path escapes root", ErrInvalidTask)
}
}
return paths, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
package downloader
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const (
TaskSchemaVersion = 1
MaxRequestIDBytes = 128
MaxAppIDBytes = 128
MaxURLBytes = 4096
MaxErrorCodeBytes = 64
MaxValidatorBytes = 512
)
var (
ErrInvalidTask = errors.New("download task is invalid")
ErrTaskConflict = errors.New("download task conflicts with an existing task")
ErrTaskNotFound = errors.New("download task not found")
ErrInvalidCommand = errors.New("download command is invalid for task state")
ErrTaskBusy = errors.New("download task is busy")
ErrQueueClosed = errors.New("download queue is closed")
ErrTaskCorrupt = errors.New("download task state is corrupt")
requestIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
appIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
errorCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{0,63}$`)
strongETagPattern = regexp.MustCompile(`^"[\x21\x23-\x7E\x80-\xFF]*"$`)
)
// TaskStatus is the durable lifecycle of one byte transfer.
type TaskStatus string
const (
StatusQueued TaskStatus = "queued"
StatusDownloading TaskStatus = "downloading"
StatusPaused TaskStatus = "paused"
StatusFailed TaskStatus = "failed"
StatusCompleted TaskStatus = "completed"
)
// EntityValidator binds a resumed range to the same remote representation.
// ETag must be strong. LastModified must be an HTTP-date.
type EntityValidator struct {
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
}
// Task is the persisted download-task.json v1 protocol.
//
// Local paths are deliberately absent. They are derived from RequestID below
// the configured downloads root so remote metadata cannot choose filesystem
// destinations.
type Task struct {
SchemaVersion int `json:"schema_version"`
RequestID string `json:"request_id"`
AppID string `json:"app_id"`
URL string `json:"url"`
Status TaskStatus `json:"status"`
Attempt uint64 `json:"attempt"`
Done int64 `json:"done"`
TotalKnown bool `json:"total_known"`
Total int64 `json:"total"`
Validator EntityValidator `json:"validator"`
ErrorCode string `json:"error_code"`
CreatedAt string `json:"created_at"`
}
// Terminal reports whether the task no longer occupies the per-app active
// task slot. Completed transfers remain queryable until a later workflow
// consumes or removes them.
func (task Task) Terminal() bool {
return task.Status == StatusCompleted
}
// Validate enforces the durable protocol independently of JSON Schema.
func (task Task) Validate() error {
if task.SchemaVersion != TaskSchemaVersion {
return fmt.Errorf("%w: schema_version=%d", ErrInvalidTask, task.SchemaVersion)
}
if !ValidRequestID(task.RequestID) {
return fmt.Errorf("%w: invalid request_id", ErrInvalidTask)
}
if !appIDPattern.MatchString(task.AppID) || len(task.AppID) > MaxAppIDBytes {
return fmt.Errorf("%w: invalid app_id", ErrInvalidTask)
}
if err := ValidateHTTPSURL(task.URL); err != nil {
return fmt.Errorf("%w: url: %v", ErrInvalidTask, err)
}
switch task.Status {
case StatusQueued, StatusDownloading, StatusPaused, StatusFailed, StatusCompleted:
default:
return fmt.Errorf("%w: invalid status %q", ErrInvalidTask, task.Status)
}
if task.Attempt > uint64(^uint64(0)>>1) {
return fmt.Errorf("%w: attempt is too large", ErrInvalidTask)
}
if task.Done < 0 {
return fmt.Errorf("%w: done=%d", ErrInvalidTask, task.Done)
}
if task.TotalKnown {
if task.Total <= 0 || task.Done > task.Total {
return fmt.Errorf(
"%w: invalid known total done=%d total=%d",
ErrInvalidTask,
task.Done,
task.Total,
)
}
if task.Status == StatusCompleted && task.Done != task.Total {
return fmt.Errorf(
"%w: completed done=%d total=%d",
ErrInvalidTask,
task.Done,
task.Total,
)
}
} else if task.Total != 0 {
return fmt.Errorf("%w: unknown total must be zero", ErrInvalidTask)
}
if err := task.Validator.Validate(); err != nil {
return fmt.Errorf("%w: validator: %v", ErrInvalidTask, err)
}
if task.Status == StatusFailed {
if !errorCodePattern.MatchString(task.ErrorCode) ||
len(task.ErrorCode) > MaxErrorCodeBytes {
return fmt.Errorf("%w: failed task needs a stable error_code", ErrInvalidTask)
}
} else if task.ErrorCode != "" {
return fmt.Errorf("%w: error_code is only valid for failed tasks", ErrInvalidTask)
}
createdAt, err := time.Parse(time.RFC3339Nano, task.CreatedAt)
if err != nil {
return fmt.Errorf("%w: created_at: %v", ErrInvalidTask, err)
}
_, offset := createdAt.Zone()
if offset != 0 {
return fmt.Errorf("%w: created_at must be UTC", ErrInvalidTask)
}
return nil
}
// Validate validates an entity validator.
func (validator EntityValidator) Validate() error {
if validator.ETag != "" && validator.LastModified != "" {
return errors.New("etag and last_modified are mutually exclusive")
}
if validator.ETag != "" {
if len(validator.ETag) > MaxValidatorBytes ||
strings.HasPrefix(strings.ToUpper(validator.ETag), "W/") ||
!strongETagPattern.MatchString(validator.ETag) {
return errors.New("etag is not a strong ETag")
}
}
if validator.LastModified != "" {
if len(validator.LastModified) > MaxValidatorBytes {
return errors.New("last_modified is too long")
}
if _, err := http.ParseTime(validator.LastModified); err != nil {
return errors.New("last_modified is not an HTTP-date")
}
}
return nil
}
// Empty reports whether no reliable resume validator is available.
func (validator EntityValidator) Empty() bool {
return validator.ETag == "" && validator.LastModified == ""
}
// HeaderValue returns the If-Range value.
func (validator EntityValidator) HeaderValue() string {
if validator.ETag != "" {
return validator.ETag
}
return validator.LastModified
}
// ValidRequestID reports whether a request ID can safely derive local names.
func ValidRequestID(requestID string) bool {
return len(requestID) <= MaxRequestIDBytes && requestIDPattern.MatchString(requestID)
}
// ValidateHTTPSURL applies the same transport restrictions as the Catalog.
func ValidateHTTPSURL(value string) error {
if value == "" || len(value) > MaxURLBytes {
return errors.New("URL length is invalid")
}
parsed, err := url.Parse(value)
if err != nil {
return errors.New("URL is malformed")
}
if parsed.Scheme != "https" ||
parsed.Host == "" ||
parsed.User != nil ||
parsed.Fragment != "" ||
!parsed.IsAbs() {
return errors.New("URL must be absolute HTTPS without user info or fragment")
}
return nil
}
func validatorsMatch(expected, actual EntityValidator) bool {
if expected.ETag != "" {
if actual.Empty() {
return true
}
return expected.ETag == actual.ETag
}
if expected.LastModified != "" {
if actual.Empty() {
return true
}
return expected.LastModified == actual.LastModified
}
return true
}
func responseValidator(header http.Header) EntityValidator {
etag := strings.TrimSpace(header.Get("ETag"))
if etag != "" &&
!strings.HasPrefix(strings.ToUpper(etag), "W/") &&
strongETagPattern.MatchString(etag) &&
len(etag) <= MaxValidatorBytes {
return EntityValidator{ETag: etag}
}
lastModified := strings.TrimSpace(header.Get("Last-Modified"))
if lastModified != "" && len(lastModified) <= MaxValidatorBytes {
if _, err := http.ParseTime(lastModified); err == nil {
return EntityValidator{LastModified: lastModified}
}
}
return EntityValidator{}
}
+89
View File
@@ -0,0 +1,89 @@
package downloader
import (
"errors"
"path/filepath"
"testing"
)
func TestTaskValidateAndDerivedPaths(t *testing.T) {
task := validTaskForTest()
if err := task.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
paths, err := DeriveTaskPaths(filepath.Join(t.TempDir(), "downloads"), task.RequestID)
if err != nil {
t.Fatalf("DeriveTaskPaths() error = %v", err)
}
for _, path := range []string{
paths.Metadata,
paths.Backup,
paths.Part,
paths.Completed,
} {
relative, err := filepath.Rel(paths.Root, path)
if err != nil || relative == ".." {
t.Fatalf("derived path escapes root: %s", path)
}
}
}
func TestTaskRejectsInvalidFields(t *testing.T) {
tests := []struct {
name string
mutate func(*Task)
}{
{name: "request id", mutate: func(task *Task) { task.RequestID = "../escape" }},
{name: "app id", mutate: func(task *Task) { task.AppID = "Bad App" }},
{name: "URL", mutate: func(task *Task) { task.URL = "http://example.invalid/app.zip" }},
{name: "state", mutate: func(task *Task) { task.Status = "unknown" }},
{name: "negative done", mutate: func(task *Task) { task.Done = -1 }},
{name: "done exceeds total", mutate: func(task *Task) { task.Done = task.Total + 1 }},
{name: "unknown total value", mutate: func(task *Task) {
task.TotalKnown = false
task.Total = 10
}},
{name: "weak etag", mutate: func(task *Task) {
task.Validator = EntityValidator{ETag: `W/"weak"`}
}},
{name: "failed without code", mutate: func(task *Task) {
task.Status = StatusFailed
task.ErrorCode = ""
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
task := validTaskForTest()
test.mutate(&task)
if err := task.Validate(); !errors.Is(err, ErrInvalidTask) {
t.Fatalf("Validate() error = %v, want %v", err, ErrInvalidTask)
}
})
}
}
func TestValidatorsMatchRejectsDifferentKinds(t *testing.T) {
expected := EntityValidator{ETag: `"v1"`}
if validatorsMatch(expected, EntityValidator{LastModified: "Wed, 16 Jul 2026 10:00:00 GMT"}) {
t.Fatal("ETag unexpectedly matched Last-Modified")
}
if validatorsMatch(expected, EntityValidator{ETag: `"v2"`}) {
t.Fatal("different ETags matched")
}
if !validatorsMatch(expected, EntityValidator{}) {
t.Fatal("absent response validator should not contradict If-Range")
}
}
func validTaskForTest() Task {
return Task{
SchemaVersion: TaskSchemaVersion,
RequestID: "request-001",
AppID: "json-parser",
URL: "https://download.invalid/json-parser.zip",
Status: StatusQueued,
TotalKnown: true,
Total: 42,
CreatedAt: "2026-07-16T00:00:00Z",
}
}
+335
View File
@@ -0,0 +1,335 @@
package downloader
import (
"context"
"fmt"
"os"
"time"
)
func (queue *Queue) transfer(
runtime *runtimeTask,
ctx context.Context,
attempt uint64,
) (completedPath string, currentAttempt uint64, transferErr error) {
currentAttempt = attempt
task, valid := queue.taskForAttempt(runtime, currentAttempt)
if !valid {
return "", currentAttempt, context.Canceled
}
paths, err := ensureTransferLayout(queue.root, task.RequestID)
if err != nil {
return "", currentAttempt, err
}
offset, _, err := regularFileSize(paths.Part)
if err != nil {
return "", currentAttempt, err
}
response, err := queue.transport.Open(ctx, OpenRequest{
URL: task.URL,
Offset: offset,
Validator: task.Validator,
})
if err != nil {
return "", currentAttempt, err
}
body := response.Body
defer func() {
if body != nil {
_ = body.Close()
}
}()
if task.TotalKnown && response.TotalKnown && response.Total != task.Total {
return "", currentAttempt, fmt.Errorf(
"%w: response total %d, expected %d",
ErrTransferIncomplete,
response.Total,
task.Total,
)
}
if !task.TotalKnown && response.TotalKnown && response.Total > queue.maxUnknown {
return "", currentAttempt, ErrTransferTooLarge
}
if !task.TotalKnown && offset > 0 && !response.Restart && !response.TotalKnown {
return "", currentAttempt, fmt.Errorf(
"%w: resumed response has unknown complete length",
ErrTransferIncomplete,
)
}
var part *os.File
if response.Restart {
part, err = openPart(paths, offset, true)
if err != nil {
return "", currentAttempt, err
}
if err := part.Sync(); err != nil {
_ = part.Close()
return "", currentAttempt, fmt.Errorf("sync restarted part: %w", err)
}
currentAttempt, task, err = queue.restartAttempt(
runtime,
currentAttempt,
response,
)
if err != nil {
_ = part.Close()
return "", currentAttempt, err
}
offset = 0
} else {
task, err = queue.applyResponseFacts(runtime, currentAttempt, response)
if err != nil {
return "", currentAttempt, err
}
}
var maximum int64
var expectedKnown bool
var expected int64
if task.TotalKnown {
if offset > task.Total {
return "", currentAttempt, fmt.Errorf(
"%w: offset exceeds total",
ErrTaskCorrupt,
)
}
maximum = task.Total - offset
expectedKnown = true
expected = maximum
} else {
if offset >= queue.maxUnknown {
return "", currentAttempt, ErrTransferTooLarge
}
maximum = queue.maxUnknown - offset
if response.ResponseLengthKnown {
expectedKnown = true
expected = response.ResponseLength
}
}
if !task.TotalKnown && response.TotalKnown {
if response.Total > queue.maxUnknown ||
offset > response.Total ||
!response.ResponseLengthKnown ||
response.ResponseLength != response.Total-offset {
if part != nil {
_ = part.Close()
}
return "", currentAttempt, ErrTransferIncomplete
}
}
if response.ResponseLengthKnown && response.ResponseLength > maximum {
if part != nil {
_ = part.Close()
}
return "", currentAttempt, ErrTransferTooLarge
}
if expectedKnown &&
response.ResponseLengthKnown &&
response.ResponseLength != expected {
if part != nil {
_ = part.Close()
}
return "", currentAttempt, ErrTransferIncomplete
}
if part == nil {
part, err = openPart(paths, offset, false)
if err != nil {
return "", currentAttempt, err
}
}
lastEventAt := queue.clock.Now()
lastEventDone := offset
lastPublishedDone := offset
copyOptions := CopyOptions{
MaxBytes: maximum,
ExpectedBytesKnown: expectedKnown,
ExpectedBytes: expected,
Generation: currentAttempt,
Progress: func(progress CopyProgress) error {
absoluteDone := offset + progress.Written
now := queue.clock.Now()
emit := queue.progressEvery == 0 ||
now.Sub(lastEventAt) >= queue.progressEvery
if err := queue.updateAttemptDone(
runtime,
currentAttempt,
absoluteDone,
); err != nil {
return err
}
if !emit {
return nil
}
speed := progressSpeed(lastEventAt, now, lastEventDone, absoluteDone)
if err := queue.persistAndPublishProgress(
runtime,
currentAttempt,
speed,
); err != nil {
return err
}
lastEventAt = now
lastEventDone = absoluteDone
lastPublishedDone = absoluteDone
return nil
},
}
written, copyErr := CopyResponse(ctx, part, body, copyOptions)
writtenInfo, statErr := part.Stat()
bodyCloseErr := body.Close()
body = nil
syncErr := syncAndClose(part)
if syncErr != nil {
return "", currentAttempt, syncErr
}
if bodyCloseErr != nil {
return "", currentAttempt, bodyCloseErr
}
if statErr != nil {
return "", currentAttempt, fmt.Errorf("stat written part: %w", statErr)
}
if err := verifyRegularIdentity(paths.Part, writtenInfo); err != nil {
return "", currentAttempt, err
}
if copyErr != nil {
return "", currentAttempt, copyErr
}
finalDone := offset + written
if err := queue.updateAttemptDone(runtime, currentAttempt, finalDone); err != nil {
return "", currentAttempt, err
}
if finalDone != lastPublishedDone {
now := queue.clock.Now()
speed := progressSpeed(lastEventAt, now, lastEventDone, finalDone)
if err := queue.persistAndPublishProgress(
runtime,
currentAttempt,
speed,
); err != nil {
return "", currentAttempt, err
}
}
if task.TotalKnown && finalDone != task.Total {
return "", currentAttempt, ErrTransferIncomplete
}
if err := activateCompleted(paths, writtenInfo); err != nil {
return "", currentAttempt, err
}
return paths.Completed, currentAttempt, nil
}
func (queue *Queue) restartAttempt(
runtime *runtimeTask,
attempt uint64,
response OpenResponse,
) (uint64, Task, error) {
queue.mu.Lock()
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
queue.mu.Unlock()
return attempt, Task{}, context.Canceled
}
runtime.task.Attempt++
runtime.task.Done = 0
runtime.task.Validator = response.Validator
task := runtime.task
newAttempt := task.Attempt
queue.mu.Unlock()
if err := queue.store.Write(task); err != nil {
return newAttempt, task, err
}
queue.publishBestEffort(Event{
Type: EventStarted,
RequestID: task.RequestID,
AppID: task.AppID,
Attempt: task.Attempt,
Done: 0,
TotalKnown: task.TotalKnown,
Total: task.Total,
})
return newAttempt, task, nil
}
func (queue *Queue) applyResponseFacts(
runtime *runtimeTask,
attempt uint64,
response OpenResponse,
) (Task, error) {
queue.mu.Lock()
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
queue.mu.Unlock()
return Task{}, context.Canceled
}
if runtime.task.Validator.Empty() {
runtime.task.Validator = response.Validator
}
task := runtime.task
queue.mu.Unlock()
if err := queue.store.Write(task); err != nil {
return Task{}, err
}
return task, nil
}
func (queue *Queue) updateAttemptDone(
runtime *runtimeTask,
attempt uint64,
done int64,
) error {
queue.mu.Lock()
defer queue.mu.Unlock()
if !queue.attemptCurrent(runtime, attempt) ||
runtime.stop != stopNone ||
done < runtime.task.Done {
return context.Canceled
}
if runtime.task.TotalKnown && done > runtime.task.Total {
return ErrTransferTooLarge
}
runtime.task.Done = done
return nil
}
func (queue *Queue) persistAndPublishProgress(
runtime *runtimeTask,
attempt uint64,
speed int64,
) error {
queue.mu.Lock()
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
queue.mu.Unlock()
return context.Canceled
}
task := runtime.task
queue.mu.Unlock()
if err := queue.store.Write(task); err != nil {
return err
}
queue.publishBestEffort(Event{
Type: EventProgress,
RequestID: task.RequestID,
AppID: task.AppID,
Attempt: task.Attempt,
Done: task.Done,
TotalKnown: task.TotalKnown,
Total: task.Total,
SpeedBytesSec: speed,
})
return nil
}
func progressSpeed(start, end time.Time, startDone, endDone int64) int64 {
elapsed := end.Sub(start)
if elapsed <= 0 || endDone <= startDone {
return 0
}
bytesPerSecond := float64(endDone-startDone) / elapsed.Seconds()
if bytesPerSecond <= 0 {
return 0
}
return int64(bytesPerSecond)
}
+3
View File
@@ -0,0 +1,3 @@
module softbox.local/core
go 1.20
+384
View File
@@ -0,0 +1,384 @@
package installer
import (
"archive/zip"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"softbox.local/core/internal/safepath"
)
var (
ErrInvalidArchive = errors.New("invalid ZIP archive")
ErrPathEscape = errors.New("ZIP path escapes payload")
ErrUnsupportedEntry = errors.New("unsupported ZIP entry")
ErrUnexpectedEntry = errors.New("unexpected ZIP package entry")
ErrDuplicateEntry = errors.New("duplicate ZIP entry")
ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported")
ErrTooManyEntries = errors.New("ZIP entry limit exceeded")
ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded")
ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded")
ErrEntrypointInvalid = errors.New("invalid package entrypoint")
ErrEntrypointMissing = errors.New("package entrypoint is missing")
ErrAppManifestMissing = errors.New("package app.json is missing")
ErrDestinationExists = errors.New("staging destination already exists")
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
)
// Extractor writes only payload/ contents from a pre-verified package ZIP.
type Extractor struct {
limits Limits
}
type ExtractResult struct {
Files int
Bytes int64
EntrypointPath string
}
type plannedEntry struct {
file *zip.File
archivePath string
outputPath string
targetPath string
directory bool
}
func NewExtractor(limits Limits) (Extractor, error) {
if err := limits.validate(); err != nil {
return Extractor{}, err
}
return Extractor{limits: limits}, nil
}
// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks.
func (extractor Extractor) ExtractFile(
zipPath string,
destination string,
entrypoint string,
) (ExtractResult, error) {
archive, err := zip.OpenReader(zipPath)
if err != nil {
return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err)
}
defer archive.Close()
return extractor.extract(&archive.Reader, destination, entrypoint)
}
func (extractor Extractor) extract(
archive *zip.Reader,
destination string,
entrypoint string,
) (result ExtractResult, err error) {
if err := extractor.limits.validate(); err != nil {
return ExtractResult{}, err
}
normalizedEntrypoint, err := normalizeEntrypoint(entrypoint)
if err != nil {
return ExtractResult{}, err
}
plan, err := extractor.preflight(archive, normalizedEntrypoint)
if err != nil {
return ExtractResult{}, err
}
destinationRoot, entrypointPath, err := planOutputPaths(
destination,
normalizedEntrypoint,
plan,
)
if err != nil {
return ExtractResult{}, err
}
if err := os.MkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil {
return ExtractResult{}, fmt.Errorf("create staging parent: %w", err)
}
if err := os.Mkdir(destinationRoot, 0o700); err != nil {
if os.IsExist(err) {
return ExtractResult{}, ErrDestinationExists
}
return ExtractResult{}, fmt.Errorf("create staging destination: %w", err)
}
complete := false
defer func() {
if !complete {
_ = os.RemoveAll(destinationRoot)
}
}()
var written int64
for _, entry := range plan {
if entry.directory {
if entry.outputPath == "" {
continue
}
if err := os.MkdirAll(entry.targetPath, 0o700); err != nil {
return ExtractResult{}, fmt.Errorf("create staging directory: %w", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil {
return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err)
}
source, err := entry.file.Open()
if err != nil {
return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err)
}
mode := os.FileMode(0o600)
if entry.file.Mode().Perm()&0o111 != 0 {
mode = 0o700
}
output, err := os.OpenFile(
entry.targetPath,
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
mode,
)
if err != nil {
source.Close()
return ExtractResult{}, fmt.Errorf("create staging file: %w", err)
}
remaining := extractor.limits.MaxUncompressedBytes - written
readLimit := remaining
if readLimit < math.MaxInt64 {
readLimit++
}
copied, copyErr := io.Copy(output, io.LimitReader(source, readLimit))
closeOutputErr := output.Close()
closeSourceErr := source.Close()
if copyErr != nil {
return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr)
}
if closeOutputErr != nil {
return ExtractResult{}, fmt.Errorf("close staging file: %w", closeOutputErr)
}
if closeSourceErr != nil {
return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr)
}
if copied > remaining {
return ExtractResult{}, ErrExpandedTooLarge
}
if uint64(copied) != entry.file.UncompressedSize64 {
return ExtractResult{}, fmt.Errorf(
"%w: %s expanded to %d bytes, header declares %d",
ErrArchiveCorrupt,
entry.archivePath,
copied,
entry.file.UncompressedSize64,
)
}
written += copied
result.Files++
}
result.Bytes = written
result.EntrypointPath = entrypointPath
complete = true
return result, nil
}
func planOutputPaths(
destination string,
entrypoint string,
plan []plannedEntry,
) (string, string, error) {
if destination == "" {
return "", "", fmt.Errorf("%w: staging destination is empty", ErrPathEscape)
}
destinationRoot, err := filepath.Abs(destination)
if err != nil {
return "", "", fmt.Errorf("%w: resolve staging destination: %v", ErrPathEscape, err)
}
destinationRoot = filepath.Clean(destinationRoot)
for index := range plan {
if plan[index].outputPath == "" {
plan[index].targetPath = destinationRoot
continue
}
target, err := safepath.JoinUnder(destinationRoot, plan[index].outputPath)
if err != nil {
return "", "", fmt.Errorf(
"%w: output %q: %v",
ErrPathEscape,
plan[index].outputPath,
err,
)
}
plan[index].targetPath = target
}
entrypointPath, err := safepath.JoinUnder(destinationRoot, entrypoint)
if err != nil {
return "", "", fmt.Errorf(
"%w: entrypoint %q: %v",
ErrEntrypointInvalid,
entrypoint,
err,
)
}
return destinationRoot, entrypointPath, nil
}
func (extractor Extractor) preflight(
archive *zip.Reader,
entrypoint string,
) ([]plannedEntry, error) {
if len(archive.File) > extractor.limits.MaxEntries {
return nil, fmt.Errorf(
"%w: got %d, limit %d",
ErrTooManyEntries,
len(archive.File),
extractor.limits.MaxEntries,
)
}
entrypointArchivePath := "payload/" + entrypoint
seenPaths := make(map[string]string, len(archive.File))
plan := make([]plannedEntry, 0, len(archive.File))
var totalUncompressed uint64
var totalCompressed uint64
appManifestFound := false
entrypointFound := false
for _, file := range archive.File {
normalized, directory, err := validateArchiveEntry(file)
if err != nil {
return nil, err
}
folded := safepath.CollisionKey(normalized)
if previous, exists := seenPaths[folded]; exists {
return nil, fmt.Errorf(
"%w: %q conflicts with %q",
ErrDuplicateEntry,
normalized,
previous,
)
}
seenPaths[folded] = normalized
if file.UncompressedSize64 > uint64(extractor.limits.MaxUncompressedBytes)-totalUncompressed {
return nil, ErrExpandedTooLarge
}
totalUncompressed += file.UncompressedSize64
if ^uint64(0)-totalCompressed < file.CompressedSize64 {
return nil, fmt.Errorf("%w: compressed size overflow", ErrInvalidArchive)
}
totalCompressed += file.CompressedSize64
if exceedsCompressionRatio(
file.UncompressedSize64,
file.CompressedSize64,
extractor.limits.MaxCompressionRatio,
) {
return nil, fmt.Errorf("%w: %s", ErrCompressionRatio, normalized)
}
switch {
case normalized == "app.json":
if directory {
return nil, fmt.Errorf("%w: app.json is a directory", ErrUnexpectedEntry)
}
appManifestFound = true
case normalized == "files.json":
if directory {
return nil, fmt.Errorf("%w: files.json is a directory", ErrUnexpectedEntry)
}
case normalized == "payload":
if !directory {
return nil, fmt.Errorf("%w: payload must be a directory", ErrUnexpectedEntry)
}
plan = append(plan, plannedEntry{
file: file,
archivePath: normalized,
outputPath: "",
directory: true,
})
case strings.HasPrefix(normalized, "payload/"):
outputPath := strings.TrimPrefix(normalized, "payload/")
plan = append(plan, plannedEntry{
file: file,
archivePath: normalized,
outputPath: outputPath,
directory: directory,
})
if normalized == entrypointArchivePath && !directory {
entrypointFound = true
}
default:
return nil, fmt.Errorf("%w: %s", ErrUnexpectedEntry, normalized)
}
}
if !appManifestFound {
return nil, ErrAppManifestMissing
}
if exceedsCompressionRatio(
totalUncompressed,
totalCompressed,
extractor.limits.MaxCompressionRatio,
) {
return nil, fmt.Errorf("%w: whole archive", ErrCompressionRatio)
}
if !entrypointFound {
return nil, fmt.Errorf("%w: %s", ErrEntrypointMissing, entrypoint)
}
return plan, nil
}
func validateArchiveEntry(file *zip.File) (string, bool, error) {
if file.Flags&0x1 != 0 {
return "", false, fmt.Errorf("%w: %s", ErrEncryptedEntry, file.Name)
}
normalized, directory, err := normalizeArchivePath(file.Name)
if err != nil {
return "", false, err
}
mode := file.Mode()
if mode&os.ModeSymlink != 0 {
return "", false, fmt.Errorf("%w: symlink %s", ErrUnsupportedEntry, normalized)
}
if directory {
if !mode.IsDir() {
return "", false, fmt.Errorf("%w: non-directory mode for %s", ErrUnsupportedEntry, normalized)
}
return normalized, true, nil
}
if !mode.IsRegular() {
return "", false, fmt.Errorf("%w: special file %s", ErrUnsupportedEntry, normalized)
}
return normalized, false, nil
}
func normalizeArchivePath(name string) (string, bool, error) {
directory := strings.HasSuffix(name, "/")
trimmed := strings.TrimSuffix(name, "/")
if err := safepath.ValidateRelative(trimmed); err != nil {
return "", false, fmt.Errorf("%w: %q: %v", ErrPathEscape, name, err)
}
return trimmed, directory, nil
}
func normalizeEntrypoint(entrypoint string) (string, error) {
if err := safepath.ValidateRelative(entrypoint); err != nil {
return "", fmt.Errorf("%w: %q: %v", ErrEntrypointInvalid, entrypoint, err)
}
if entrypoint == "payload" || strings.HasPrefix(entrypoint, "payload/") {
return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint)
}
return entrypoint, nil
}
func exceedsCompressionRatio(uncompressed, compressed uint64, maximum float64) bool {
if uncompressed == 0 {
return false
}
if compressed == 0 {
return true
}
return float64(uncompressed)/float64(compressed) > maximum
}
+498
View File
@@ -0,0 +1,498 @@
package installer
import (
"archive/zip"
"bytes"
"errors"
"math"
"os"
"path/filepath"
"testing"
)
func TestExtractorExtractsPayloadOnly(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{"entrypoint":"bin/App.exe"}`)},
{name: "files.json", body: []byte(`{"files":[]}`)},
{name: "payload/bin/", mode: os.ModeDir | 0o755},
{name: "payload/bin/App.exe", body: []byte("executable"), mode: 0o755},
{name: "payload/readme.txt", body: []byte("hello")},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe")
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 2 {
t.Fatalf("Files = %d, want 2", result.Files)
}
if result.Bytes != int64(len("executable")+len("hello")) {
t.Fatalf("Bytes = %d, want %d", result.Bytes, len("executable")+len("hello"))
}
if _, err := os.Stat(result.EntrypointPath); err != nil {
t.Fatalf("entrypoint stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(destination, "app.json")); !os.IsNotExist(err) {
t.Fatalf("app.json should not be extracted, stat error = %v", err)
}
}
func TestExtractorRejectsAttackArchives(t *testing.T) {
base := []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
}
tests := []struct {
name string
entries []testZIPEntry
entrypoint string
limits Limits
wantErr error
}{
{
name: "absolute path",
entries: appendEntries(base,
testZIPEntry{name: "/payload/evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "drive path",
entries: appendEntries(base,
testZIPEntry{name: "C:/payload/evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "ADS path",
entries: appendEntries(base,
testZIPEntry{name: "payload/App.exe:stream", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "dot dot traversal",
entries: appendEntries(base,
testZIPEntry{name: "payload/../evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "Windows normalized dot dot traversal",
entries: appendEntries(base,
testZIPEntry{name: "payload/.. /escape.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "trailing period",
entries: appendEntries(base,
testZIPEntry{name: "payload/evil.exe.", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "trailing space",
entries: appendEntries(base,
testZIPEntry{name: "payload/evil.exe ", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "leading space",
entries: appendEntries(base,
testZIPEntry{name: "payload/ evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "reserved device",
entries: appendEntries(base,
testZIPEntry{name: "payload/NUL.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "reserved device with space before extension",
entries: appendEntries(base,
testZIPEntry{name: "payload/NUL .txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "reserved device superscript",
entries: appendEntries(base,
testZIPEntry{name: "payload/COM¹.log", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "forbidden Windows character",
entries: appendEntries(base,
testZIPEntry{name: "payload/evil?.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "backslash traversal",
entries: appendEntries(base,
testZIPEntry{name: `payload\..\evil.exe`, body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "encrypted entry",
entries: appendEntries(base,
testZIPEntry{name: "payload/secret.bin", body: []byte("x"), flags: 0x1}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrEncryptedEntry,
},
{
name: "symlink",
entries: appendEntries(base,
testZIPEntry{
name: "payload/link",
body: []byte("../../outside"),
mode: os.ModeSymlink | 0o777,
}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrUnsupportedEntry,
},
{
name: "special file",
entries: appendEntries(base,
testZIPEntry{name: "payload/pipe", mode: os.ModeNamedPipe | 0o600}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrUnsupportedEntry,
},
{
name: "case folded duplicate",
entries: appendEntries(base,
testZIPEntry{name: "payload/app.exe", body: []byte("duplicate")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrDuplicateEntry,
},
{
name: "Unicode folded duplicate",
entries: []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/K.exe", body: []byte("one")},
{name: "payload/K.exe", body: []byte("two")},
},
entrypoint: "K.exe",
limits: testLimits(),
wantErr: ErrDuplicateEntry,
},
{
name: "unexpected top level entry",
entries: appendEntries(base,
testZIPEntry{name: "install.bat", body: []byte("echo unsafe")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrUnexpectedEntry,
},
{
name: "too many entries",
entries: appendEntries(base,
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 2,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
},
wantErr: ErrTooManyEntries,
},
{
name: "expanded size",
entries: []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("0123456789")},
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
},
wantErr: ErrExpandedTooLarge,
},
{
name: "compression ratio",
entries: []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{
name: "payload/App.exe",
body: bytes.Repeat([]byte("A"), 4096),
method: zip.Deflate,
},
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
},
wantErr: ErrCompressionRatio,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeTestZIP(t, test.entries)
root := t.TempDir()
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("rejected archive left staging, stat error = %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) {
t.Fatalf("rejected archive wrote outside staging, stat error = %v", statErr)
}
})
}
}
func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
tests := []struct {
entrypoint string
wantErr error
}{
{entrypoint: "../App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "/App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: `..\App.exe`, wantErr: ErrEntrypointInvalid},
{entrypoint: ".. /App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "App.exe.", wantErr: ErrEntrypointInvalid},
{entrypoint: "App.exe ", wantErr: ErrEntrypointInvalid},
{entrypoint: " App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "NUL", wantErr: ErrEntrypointInvalid},
{entrypoint: "bad?.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "payload/App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "Missing.exe", wantErr: ErrEntrypointMissing},
}
for _, test := range tests {
t.Run(test.entrypoint, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("invalid entrypoint left staging, stat error = %v", statErr)
}
})
}
}
func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/工具/解析器.exe", body: []byte("executable")},
{name: "payload/资源/说明.txt", body: []byte("说明")},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe")
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 2 {
t.Fatalf("Files = %d, want 2", result.Files)
}
if _, err := os.Stat(result.EntrypointPath); err != nil {
t.Fatalf("entrypoint stat error = %v", err)
}
}
func TestExtractorRejectsExistingDestination(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
destination := filepath.Join(t.TempDir(), "staging")
if err := os.Mkdir(destination, 0o700); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
if !errors.Is(err, ErrDestinationExists) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
}
}
func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
})
corruptZIPEntryData(t, archivePath, "payload/App.exe")
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
if !errors.Is(err, ErrArchiveCorrupt) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("copy failure left staging, stat error = %v", statErr)
}
}
type testZIPEntry struct {
name string
body []byte
mode os.FileMode
method uint16
flags uint16
}
func testLimits() Limits {
return Limits{
MaxEntries: 20,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
}
}
func mustExtractor(t *testing.T, limits Limits) Extractor {
t.Helper()
extractor, err := NewExtractor(limits)
if err != nil {
t.Fatalf("NewExtractor() error = %v", err)
}
return extractor
}
func appendEntries(base []testZIPEntry, extra ...testZIPEntry) []testZIPEntry {
result := append([]testZIPEntry(nil), base...)
return append(result, extra...)
}
func writeTestZIP(t *testing.T, entries []testZIPEntry) string {
t.Helper()
path := filepath.Join(t.TempDir(), "package.zip")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create ZIP: %v", err)
}
writer := zip.NewWriter(file)
for _, entry := range entries {
header := &zip.FileHeader{
Name: entry.name,
Method: entry.method,
Flags: entry.flags,
}
mode := entry.mode
if mode == 0 {
mode = 0o600
}
header.SetMode(mode)
part, err := writer.CreateHeader(header)
if err != nil {
writer.Close()
file.Close()
t.Fatalf("create ZIP entry %s: %v", entry.name, err)
}
if _, err := part.Write(entry.body); err != nil {
writer.Close()
file.Close()
t.Fatalf("write ZIP entry %s: %v", entry.name, err)
}
}
if err := writer.Close(); err != nil {
file.Close()
t.Fatalf("close ZIP writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close ZIP file: %v", err)
}
return path
}
func corruptZIPEntryData(t *testing.T, archivePath, entryName string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open ZIP for corruption: %v", err)
}
var offset int64 = -1
for _, file := range reader.File {
if file.Name == entryName {
offset, err = file.DataOffset()
if err != nil {
reader.Close()
t.Fatalf("entry data offset: %v", err)
}
break
}
}
if err := reader.Close(); err != nil {
t.Fatalf("close ZIP reader: %v", err)
}
if offset < 0 {
t.Fatalf("entry %s not found", entryName)
}
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP for corruption: %v", err)
}
data[offset] ^= 0xff
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write corrupted ZIP: %v", err)
}
}
func TestDefaultLimitsAreValid(t *testing.T) {
if _, err := NewExtractor(DefaultLimits()); err != nil {
t.Fatalf("NewExtractor(DefaultLimits()) error = %v", err)
}
}
func TestExtractorRejectsInvalidLimits(t *testing.T) {
tests := []Limits{
{MaxEntries: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
}
for index, limits := range tests {
if _, err := NewExtractor(limits); !errors.Is(err, ErrInvalidLimits) {
t.Errorf("case %d NewExtractor() error = %v, want %v", index, err, ErrInvalidLimits)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build windows
package installer
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
{name: "payload/.. /escape.exe", body: []byte("escape")},
})
root := t.TempDir()
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
if !errors.Is(err, ErrPathEscape) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("rejected archive left staging, stat error = %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) {
t.Fatalf("archive escaped staging, stat error = %v", statErr)
}
}
+110
View File
@@ -0,0 +1,110 @@
package installer
import (
"errors"
"fmt"
"os"
"path/filepath"
)
var (
ErrUnsafeInstallLayout = errors.New("unsafe install directory layout")
ErrStagingMissing = errors.New("staging directory is missing")
ErrBackupExists = errors.New("backup directory already exists")
)
type appLayout struct {
root string
current string
staging string
backup string
transaction string
transactionBackup string
}
type directoryState struct {
current bool
staging bool
backup bool
}
func inspectAppLayout(root string) (appLayout, error) {
if root == "" {
return appLayout{}, fmt.Errorf("%w: empty root", ErrUnsafeInstallLayout)
}
absolute, err := filepath.Abs(root)
if err != nil {
return appLayout{}, fmt.Errorf("%w: %v", ErrUnsafeInstallLayout, err)
}
info, err := os.Lstat(absolute)
if err != nil {
return appLayout{}, fmt.Errorf("%w: root: %v", ErrUnsafeInstallLayout, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return appLayout{}, fmt.Errorf("%w: root is not a real directory", ErrUnsafeInstallLayout)
}
transaction, transactionBackup := transactionPaths(absolute)
return appLayout{
root: absolute,
current: filepath.Join(absolute, "current"),
staging: filepath.Join(absolute, "staging"),
backup: filepath.Join(absolute, "backup"),
transaction: transaction,
transactionBackup: transactionBackup,
}, nil
}
func inspectDirectories(layout appLayout) (directoryState, error) {
current, err := inspectManagedDirectory(layout.current)
if err != nil {
return directoryState{}, err
}
staging, err := inspectManagedDirectory(layout.staging)
if err != nil {
return directoryState{}, err
}
backup, err := inspectManagedDirectory(layout.backup)
if err != nil {
return directoryState{}, err
}
return directoryState{
current: current,
staging: staging,
backup: backup,
}, nil
}
func inspectManagedDirectory(path string) (bool, error) {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("%w: inspect %s: %v", ErrUnsafeInstallLayout, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return false, fmt.Errorf("%w: %s is not a real directory", ErrUnsafeInstallLayout, path)
}
return true, nil
}
func removeManagedDirectory(layout appLayout, target string) error {
if filepath.Dir(target) != layout.root {
return fmt.Errorf("%w: refuse removal outside app root", ErrUnsafeInstallLayout)
}
base := filepath.Base(target)
if base != "current" && base != "staging" && base != "backup" {
return fmt.Errorf("%w: refuse removal of %s", ErrUnsafeInstallLayout, base)
}
exists, err := inspectManagedDirectory(target)
if err != nil {
return err
}
if !exists {
return nil
}
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("remove managed directory %s: %w", base, err)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package installer
import (
"errors"
"fmt"
"math"
)
var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
const (
DefaultMaxEntries = 10_000
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCompressionRatio = 200.0
)
// Limits bounds archive metadata and decompressed output.
type Limits struct {
MaxEntries int
MaxUncompressedBytes int64
MaxCompressionRatio float64
}
func DefaultLimits() Limits {
return Limits{
MaxEntries: DefaultMaxEntries,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
}
}
func (limits Limits) validate() error {
if limits.MaxEntries <= 0 {
return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits)
}
if limits.MaxUncompressedBytes <= 0 {
return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCompressionRatio <= 0 ||
math.IsNaN(limits.MaxCompressionRatio) ||
math.IsInf(limits.MaxCompressionRatio, 0) {
return fmt.Errorf("%w: MaxCompressionRatio must be finite and positive", ErrInvalidLimits)
}
return nil
}
+173
View File
@@ -0,0 +1,173 @@
package installer
import (
"errors"
"fmt"
"os"
)
var ErrRecoveryInconsistent = errors.New("install recovery state is inconsistent")
type RecoveryAction string
const (
RecoveryNone RecoveryAction = "none"
RecoveryAborted RecoveryAction = "aborted"
RecoveryRolledBack RecoveryAction = "rolled_back"
RecoveryCommitted RecoveryAction = "committed"
)
type RecoveryResult struct {
Action RecoveryAction
Phase string
}
// Recover resolves an interrupted transaction from journal and directory state.
func Recover(root string) (RecoveryResult, error) {
layout, err := inspectAppLayout(root)
if err != nil {
return RecoveryResult{}, err
}
record, exists, err := loadTransaction(layout)
if err != nil {
return RecoveryResult{}, err
}
state, err := inspectDirectories(layout)
if err != nil {
return RecoveryResult{}, err
}
if !exists {
if state.backup {
return RecoveryResult{}, fmt.Errorf(
"%w: backup exists without transaction",
ErrRecoveryInconsistent,
)
}
return RecoveryResult{Action: RecoveryNone}, nil
}
result := RecoveryResult{Phase: string(record.Phase)}
switch record.Phase {
case phaseCommitted:
if !state.current || state.staging {
return RecoveryResult{}, fmt.Errorf(
"%w: committed current=%t staging=%t",
ErrRecoveryInconsistent,
state.current,
state.staging,
)
}
if err := removeManagedDirectory(layout, layout.backup); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryCommitted
return result, nil
case phaseRollbackRequired:
return recoverRollbackRequired(layout, record, state, result)
case phasePrepared, phaseCurrentBackedUp, phaseStagingActivated:
return recoverUncommitted(layout, record, state, result)
default:
return RecoveryResult{}, fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase)
}
}
func recoverUncommitted(
layout appLayout,
record transactionRecord,
state directoryState,
result RecoveryResult,
) (RecoveryResult, error) {
if record.HadCurrent {
if state.backup {
if state.current && state.staging {
return RecoveryResult{}, fmt.Errorf(
"%w: current, staging and backup all exist",
ErrRecoveryInconsistent,
)
}
if state.current {
if err := os.Rename(layout.current, layout.staging); err != nil {
return RecoveryResult{}, fmt.Errorf("move unverified current aside: %w", err)
}
}
if err := os.Rename(layout.backup, layout.current); err != nil {
return RecoveryResult{}, fmt.Errorf("restore backup during recovery: %w", err)
}
if err := removeManagedDirectory(layout, layout.staging); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryRolledBack
return result, nil
}
if record.Phase == phasePrepared && state.current && state.staging {
if err := removeManagedDirectory(layout, layout.staging); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryAborted
return result, nil
}
return RecoveryResult{}, fmt.Errorf(
"%w: old current has no recoverable backup",
ErrRecoveryInconsistent,
)
}
if state.backup || (state.current && state.staging) {
return RecoveryResult{}, fmt.Errorf(
"%w: initial install has conflicting directories",
ErrRecoveryInconsistent,
)
}
if err := removeManagedDirectory(layout, layout.current); err != nil {
return RecoveryResult{}, err
}
if err := removeManagedDirectory(layout, layout.staging); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryAborted
return result, nil
}
func recoverRollbackRequired(
layout appLayout,
record transactionRecord,
state directoryState,
result RecoveryResult,
) (RecoveryResult, error) {
if record.HadCurrent && !state.backup {
if !state.current {
return RecoveryResult{}, fmt.Errorf(
"%w: rollback lost current and backup",
ErrRecoveryInconsistent,
)
}
if err := removeManagedDirectory(layout, layout.staging); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryRolledBack
return result, nil
}
if err := rollbackActivated(layout, record.HadCurrent); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryRolledBack
return result, nil
}
+196
View File
@@ -0,0 +1,196 @@
package installer
import (
"errors"
"fmt"
"os"
)
var (
ErrHealthCheckRequired = errors.New("health check is required")
ErrHealthCheckFailed = errors.New("installed version failed health check")
ErrRollbackFailed = errors.New("install rollback failed")
)
type HealthCheck func(currentPath string) error
type switchStep string
const (
stepPrepared switchStep = "prepared"
stepCurrentRenamed switchStep = "current_renamed"
stepCurrentBackedUp switchStep = "current_backed_up"
stepStagingRenamed switchStep = "staging_renamed"
stepStagingActivated switchStep = "staging_activated"
stepRollbackRequired switchStep = "rollback_required"
stepCommitted switchStep = "committed"
)
// RollbackError reports both the health failure and rollback failure.
type RollbackError struct {
Health error
Rollback error
}
func (err *RollbackError) Error() string {
return fmt.Sprintf("%s: health=%v; rollback=%v", ErrRollbackFailed, err.Health, err.Rollback)
}
func (err *RollbackError) Unwrap() error {
return ErrRollbackFailed
}
// Switcher activates a verified staging directory and runs an injected check.
type Switcher struct {
health HealthCheck
afterStep func(switchStep) error
}
func NewSwitcher(health HealthCheck) *Switcher {
return &Switcher{health: health}
}
func (switcher *Switcher) Switch(root string) error {
if switcher.health == nil {
return ErrHealthCheckRequired
}
layout, err := inspectAppLayout(root)
if err != nil {
return err
}
if _, exists, err := loadTransaction(layout); err != nil {
return err
} else if exists {
return ErrRecoveryRequired
}
state, err := inspectDirectories(layout)
if err != nil {
return err
}
if !state.staging {
return ErrStagingMissing
}
if state.backup {
return ErrBackupExists
}
record := newTransaction(phasePrepared, state.current)
if err := writeTransaction(layout, record); err != nil {
return err
}
if err := switcher.runStep(stepPrepared); err != nil {
return err
}
if state.current {
if err := os.Rename(layout.current, layout.backup); err != nil {
return fmt.Errorf("backup current directory: %w", err)
}
if err := switcher.runStep(stepCurrentRenamed); err != nil {
return err
}
}
record.Phase = phaseCurrentBackedUp
if err := writeTransaction(layout, record); err != nil {
return err
}
if err := switcher.runStep(stepCurrentBackedUp); err != nil {
return err
}
if err := os.Rename(layout.staging, layout.current); err != nil {
return fmt.Errorf("activate staging directory: %w", err)
}
if err := switcher.runStep(stepStagingRenamed); err != nil {
return err
}
record.Phase = phaseStagingActivated
if err := writeTransaction(layout, record); err != nil {
return err
}
if err := switcher.runStep(stepStagingActivated); err != nil {
return err
}
healthErr := switcher.health(layout.current)
if healthErr != nil {
record.Phase = phaseRollbackRequired
if err := writeTransaction(layout, record); err != nil {
return &RollbackError{Health: healthErr, Rollback: err}
}
if err := switcher.runStep(stepRollbackRequired); err != nil {
return err
}
if err := rollbackActivated(layout, record.HadCurrent); err != nil {
return &RollbackError{Health: healthErr, Rollback: err}
}
if err := removeTransaction(layout); err != nil {
return &RollbackError{Health: healthErr, Rollback: err}
}
return fmt.Errorf("%w: %v", ErrHealthCheckFailed, healthErr)
}
record.Phase = phaseCommitted
if err := writeTransaction(layout, record); err != nil {
return err
}
if err := switcher.runStep(stepCommitted); err != nil {
return err
}
if record.HadCurrent {
if err := removeManagedDirectory(layout, layout.backup); err != nil {
return fmt.Errorf("%w: cleanup committed backup: %v", ErrRecoveryRequired, err)
}
}
if err := removeTransaction(layout); err != nil {
return fmt.Errorf("%w: %v", ErrRecoveryRequired, err)
}
return nil
}
func (switcher *Switcher) runStep(step switchStep) error {
if switcher.afterStep == nil {
return nil
}
return switcher.afterStep(step)
}
func rollbackActivated(layout appLayout, hadCurrent bool) error {
state, err := inspectDirectories(layout)
if err != nil {
return err
}
if hadCurrent {
if !state.backup {
return fmt.Errorf("%w: previous current backup is missing", ErrRollbackFailed)
}
if state.current {
if state.staging {
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
}
if err := os.Rename(layout.current, layout.staging); err != nil {
return fmt.Errorf("move failed current aside: %w", err)
}
}
if err := os.Rename(layout.backup, layout.current); err != nil {
if _, statErr := os.Stat(layout.staging); statErr == nil {
_ = os.Rename(layout.staging, layout.current)
}
return fmt.Errorf("restore previous current: %w", err)
}
return removeManagedDirectory(layout, layout.staging)
}
if state.backup {
return fmt.Errorf("%w: unexpected backup without previous current", ErrRollbackFailed)
}
if state.current {
if state.staging {
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
}
if err := os.Rename(layout.current, layout.staging); err != nil {
return fmt.Errorf("move failed initial install aside: %w", err)
}
}
return removeManagedDirectory(layout, layout.staging)
}
+295
View File
@@ -0,0 +1,295 @@
package installer
import (
"errors"
"os"
"path/filepath"
"testing"
)
var errSimulatedCrash = errors.New("simulated crash")
func TestSwitcherCommitsHealthyUpdate(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
switcher := NewSwitcher(func(currentPath string) error {
assertVersion(t, currentPath, "new")
return nil
})
if err := switcher.Switch(root); err != nil {
t.Fatalf("Switch() error = %v", err)
}
assertVersion(t, filepath.Join(root, "current"), "new")
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, "backup"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestSwitcherRollsBackFailedUpdate(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
healthFailure := errors.New("new version did not start")
switcher := NewSwitcher(func(string) error {
return healthFailure
})
err := switcher.Switch(root)
if !errors.Is(err, ErrHealthCheckFailed) {
t.Fatalf("Switch() error = %v, want %v", err, ErrHealthCheckFailed)
}
assertVersion(t, filepath.Join(root, "current"), "old")
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, "backup"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestSwitcherRemovesFailedInitialInstall(t *testing.T) {
root := makeInstallRoot(t, "", "new")
switcher := NewSwitcher(func(string) error {
return errors.New("health failed")
})
err := switcher.Switch(root)
if !errors.Is(err, ErrHealthCheckFailed) {
t.Fatalf("Switch() error = %v, want %v", err, ErrHealthCheckFailed)
}
assertMissing(t, filepath.Join(root, "current"))
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestRecoverInterruptedSwitch(t *testing.T) {
tests := []struct {
name string
crashStep switchStep
wantVersion string
wantAction RecoveryAction
}{
{
name: "after current rename before phase update",
crashStep: stepCurrentRenamed,
wantVersion: "old",
wantAction: RecoveryRolledBack,
},
{
name: "after staging rename before phase update",
crashStep: stepStagingRenamed,
wantVersion: "old",
wantAction: RecoveryRolledBack,
},
{
name: "after committed journal before cleanup",
crashStep: stepCommitted,
wantVersion: "new",
wantAction: RecoveryCommitted,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
switcher := NewSwitcher(func(string) error { return nil })
switcher.afterStep = func(step switchStep) error {
if step == test.crashStep {
return errSimulatedCrash
}
return nil
}
err := switcher.Switch(root)
if !errors.Is(err, errSimulatedCrash) {
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != test.wantAction {
t.Fatalf("Action = %q, want %q", result.Action, test.wantAction)
}
assertVersion(t, filepath.Join(root, "current"), test.wantVersion)
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, "backup"))
assertMissing(t, filepath.Join(root, transactionFileName))
})
}
}
func TestRecoverReadsTransactionBackup(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
switcher := NewSwitcher(func(string) error { return nil })
switcher.afterStep = func(step switchStep) error {
if step == stepStagingRenamed {
return errSimulatedCrash
}
return nil
}
if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) {
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
}
if err := os.Rename(
filepath.Join(root, transactionFileName),
filepath.Join(root, transactionBackupFileName),
); err != nil {
t.Fatalf("move transaction to backup: %v", err)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryRolledBack {
t.Fatalf("Action = %q, want %q", result.Action, RecoveryRolledBack)
}
assertVersion(t, filepath.Join(root, "current"), "old")
assertMissing(t, filepath.Join(root, transactionBackupFileName))
}
func TestRecoverAbortsInterruptedInitialInstall(t *testing.T) {
root := makeInstallRoot(t, "", "new")
switcher := NewSwitcher(func(string) error { return nil })
switcher.afterStep = func(step switchStep) error {
if step == stepStagingRenamed {
return errSimulatedCrash
}
return nil
}
if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) {
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryAborted {
t.Fatalf("Action = %q, want %q", result.Action, RecoveryAborted)
}
assertMissing(t, filepath.Join(root, "current"))
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestRecoverCompletesInterruptedRollback(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
switcher := NewSwitcher(func(string) error {
return errors.New("health failed")
})
switcher.afterStep = func(step switchStep) error {
if step == stepRollbackRequired {
return errSimulatedCrash
}
return nil
}
if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) {
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryRolledBack {
t.Fatalf("Action = %q, want %q", result.Action, RecoveryRolledBack)
}
assertVersion(t, filepath.Join(root, "current"), "old")
assertMissing(t, filepath.Join(root, "staging"))
assertMissing(t, filepath.Join(root, "backup"))
}
func TestSwitcherRejectsUnsafeStartingState(t *testing.T) {
t.Run("missing staging", func(t *testing.T) {
root := makeInstallRoot(t, "old", "")
err := NewSwitcher(func(string) error { return nil }).Switch(root)
if !errors.Is(err, ErrStagingMissing) {
t.Fatalf("Switch() error = %v, want %v", err, ErrStagingMissing)
}
})
t.Run("existing backup", func(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
writeVersion(t, filepath.Join(root, "backup"), "stale")
err := NewSwitcher(func(string) error { return nil }).Switch(root)
if !errors.Is(err, ErrBackupExists) {
t.Fatalf("Switch() error = %v, want %v", err, ErrBackupExists)
}
})
t.Run("staging symlink", func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "target")
writeVersion(t, target, "new")
if err := os.Symlink(target, filepath.Join(root, "staging")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
err := NewSwitcher(func(string) error { return nil }).Switch(root)
if !errors.Is(err, ErrUnsafeInstallLayout) {
t.Fatalf("Switch() error = %v, want %v", err, ErrUnsafeInstallLayout)
}
})
t.Run("transaction symlink", func(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
target := filepath.Join(t.TempDir(), "transaction.json")
if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil {
t.Fatalf("write symlink target: %v", err)
}
if err := os.Symlink(target, filepath.Join(root, transactionFileName)); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
err := NewSwitcher(func(string) error { return nil }).Switch(root)
if !errors.Is(err, ErrUnsafeInstallLayout) {
t.Fatalf("Switch() error = %v, want %v", err, ErrUnsafeInstallLayout)
}
})
}
func TestRecoverRejectsOrphanBackup(t *testing.T) {
root := makeInstallRoot(t, "old", "")
writeVersion(t, filepath.Join(root, "backup"), "orphan")
_, err := Recover(root)
if !errors.Is(err, ErrRecoveryInconsistent) {
t.Fatalf("Recover() error = %v, want %v", err, ErrRecoveryInconsistent)
}
}
func makeInstallRoot(t *testing.T, currentVersion, stagingVersion string) string {
t.Helper()
root := t.TempDir()
if currentVersion != "" {
writeVersion(t, filepath.Join(root, "current"), currentVersion)
}
if stagingVersion != "" {
writeVersion(t, filepath.Join(root, "staging"), stagingVersion)
}
return root
}
func writeVersion(t *testing.T, directory, version string) {
t.Helper()
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatalf("create version directory: %v", err)
}
if err := os.WriteFile(filepath.Join(directory, "version.txt"), []byte(version), 0o600); err != nil {
t.Fatalf("write version: %v", err)
}
}
func assertVersion(t *testing.T, directory, want string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(directory, "version.txt"))
if err != nil {
t.Fatalf("read version from %s: %v", directory, err)
}
if string(data) != want {
t.Fatalf("version in %s = %q, want %q", directory, data, want)
}
}
func assertMissing(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%s should be missing, stat error = %v", path, err)
}
}
+209
View File
@@ -0,0 +1,209 @@
package installer
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
const (
transactionFileName = "install-transaction.json"
transactionBackupFileName = "install-transaction.json.backup"
transactionSchemaVersion = 1
)
type transactionPhase string
const (
phasePrepared transactionPhase = "prepared"
phaseCurrentBackedUp transactionPhase = "current_backed_up"
phaseStagingActivated transactionPhase = "staging_activated"
phaseRollbackRequired transactionPhase = "rollback_required"
phaseCommitted transactionPhase = "committed"
)
var (
ErrTransactionCorrupt = errors.New("install transaction is corrupt")
ErrRecoveryRequired = errors.New("install recovery is required")
)
type transactionRecord struct {
SchemaVersion int `json:"schema_version"`
Phase transactionPhase `json:"phase"`
HadCurrent bool `json:"had_current"`
}
func newTransaction(phase transactionPhase, hadCurrent bool) transactionRecord {
return transactionRecord{
SchemaVersion: transactionSchemaVersion,
Phase: phase,
HadCurrent: hadCurrent,
}
}
func (record transactionRecord) validate() error {
if record.SchemaVersion != transactionSchemaVersion {
return fmt.Errorf(
"%w: schema_version=%d",
ErrTransactionCorrupt,
record.SchemaVersion,
)
}
switch record.Phase {
case phasePrepared,
phaseCurrentBackedUp,
phaseStagingActivated,
phaseRollbackRequired,
phaseCommitted:
return nil
default:
return fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase)
}
}
func writeTransaction(layout appLayout, record transactionRecord) error {
if err := record.validate(); err != nil {
return err
}
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("encode install transaction: %w", err)
}
data = append(data, '\n')
if err := replaceFileWithBackup(
layout.root,
layout.transaction,
layout.transactionBackup,
data,
); err != nil {
return fmt.Errorf("write install transaction: %w", err)
}
return nil
}
func loadTransaction(layout appLayout) (transactionRecord, bool, error) {
data, err := readTransactionFile(layout.transaction)
if os.IsNotExist(err) {
data, err = readTransactionFile(layout.transactionBackup)
}
if os.IsNotExist(err) {
return transactionRecord{}, false, nil
}
if err != nil {
return transactionRecord{}, false, fmt.Errorf("read install transaction: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var record transactionRecord
if err := decoder.Decode(&record); err != nil {
return transactionRecord{}, false, fmt.Errorf("%w: %v", ErrTransactionCorrupt, err)
}
if err := ensureJSONEOF(decoder); err != nil {
return transactionRecord{}, false, err
}
if err := record.validate(); err != nil {
return transactionRecord{}, false, err
}
return record, true, nil
}
func readTransactionFile(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, fmt.Errorf("%w: transaction is not a regular file", ErrUnsafeInstallLayout)
}
return os.ReadFile(path)
}
func ensureJSONEOF(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", ErrTransactionCorrupt)
}
return fmt.Errorf("%w: trailing data: %v", ErrTransactionCorrupt, err)
}
return nil
}
func removeTransaction(layout appLayout) error {
if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove install transaction: %w", err)
}
if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove install transaction backup: %w", err)
}
return nil
}
func replaceFileWithBackup(
directory string,
target string,
backup string,
data []byte,
) error {
temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
movedTarget := false
if info, err := os.Lstat(target); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%w: transaction target is not a regular file", ErrUnsafeInstallLayout)
}
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Rename(target, backup); err != nil {
return err
}
movedTarget = true
} else if !os.IsNotExist(err) {
return err
}
if err := os.Rename(temporaryPath, target); err != nil {
if movedTarget {
_ = os.Rename(backup, target)
}
return err
}
if movedTarget {
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func transactionPaths(root string) (string, string) {
return filepath.Join(root, transactionFileName),
filepath.Join(root, transactionBackupFileName)
}
+167
View File
@@ -0,0 +1,167 @@
// Package safepath validates package-controlled paths before they reach the
// Windows filesystem.
package safepath
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
)
var (
ErrInvalidRelativePath = errors.New("invalid Windows relative path")
ErrOutsideRoot = errors.New("path is outside the managed root")
)
// ValidateRelative accepts a canonical, slash-separated relative path that
// names a file or directory below a managed root.
func ValidateRelative(value string) error {
return validate(value, false)
}
// ValidateWorkingDirectory accepts "." or a canonical relative directory.
func ValidateWorkingDirectory(value string) error {
return validate(value, true)
}
func validate(value string, allowCurrent bool) error {
if value == "." && allowCurrent {
return nil
}
if value == "" {
return invalid("path is empty")
}
if !utf8.ValidString(value) {
return invalid("path is not valid UTF-8")
}
if strings.HasPrefix(value, "/") || path.IsAbs(value) {
return invalid("absolute paths are forbidden")
}
if strings.ContainsRune(value, '\\') {
return invalid("backslashes are forbidden")
}
if cleaned := path.Clean(value); cleaned != value {
return invalid("path is not canonical")
}
segments := strings.Split(value, "/")
for _, segment := range segments {
if err := validateSegment(segment); err != nil {
return err
}
}
return nil
}
func validateSegment(segment string) error {
if segment == "" {
return invalid("empty path segment")
}
if segment == "." || segment == ".." {
return invalid("relative dot segment")
}
if strings.HasPrefix(segment, " ") ||
strings.HasSuffix(segment, " ") ||
strings.HasSuffix(segment, ".") {
return invalid("path segment uses a Windows-trimmed character")
}
for _, character := range segment {
if unicode.IsControl(character) || strings.ContainsRune(`<>:"\|?*`, character) {
return invalid("path segment contains a Windows-forbidden character")
}
}
if isReservedDeviceName(segment) {
return invalid("path segment is a reserved Windows device name")
}
return nil
}
func isReservedDeviceName(segment string) bool {
base := segment
if dot := strings.IndexRune(base, '.'); dot >= 0 {
base = base[:dot]
}
base = strings.TrimRight(base, " ")
upper := strings.ToUpper(base)
switch upper {
case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$":
return true
}
if len(upper) == 4 {
prefix := upper[:3]
digit := upper[3]
if (prefix == "COM" || prefix == "LPT") && digit >= '1' && digit <= '9' {
return true
}
}
if len([]rune(upper)) == 4 {
runes := []rune(upper)
prefix := string(runes[:3])
digit := runes[3]
if (prefix == "COM" || prefix == "LPT") &&
(digit == '\u00b9' || digit == '\u00b2' || digit == '\u00b3') {
return true
}
}
return false
}
// CollisionKey returns a stable Unicode simple-fold key for case-insensitive
// duplicate detection. Callers should validate the path first.
func CollisionKey(value string) string {
var builder strings.Builder
builder.Grow(len(value))
for _, character := range value {
builder.WriteRune(foldRune(character))
}
return builder.String()
}
func foldRune(character rune) rune {
smallest := character
for next := unicode.SimpleFold(character); next != character; next = unicode.SimpleFold(next) {
if next < smallest {
smallest = next
}
}
return smallest
}
// JoinUnder joins a validated relative path below root and verifies the
// resulting native path remains lexically contained by that root.
func JoinUnder(root, relative string) (string, error) {
if root == "" {
return "", fmt.Errorf("%w: root is empty", ErrOutsideRoot)
}
if err := ValidateRelative(relative); err != nil {
return "", err
}
absoluteRoot, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("%w: resolve root: %v", ErrOutsideRoot, err)
}
absoluteRoot = filepath.Clean(absoluteRoot)
target := filepath.Clean(filepath.Join(
absoluteRoot,
filepath.FromSlash(relative),
))
relativeTarget, err := filepath.Rel(absoluteRoot, target)
if err != nil {
return "", fmt.Errorf("%w: compare target: %v", ErrOutsideRoot, err)
}
if relativeTarget == ".." ||
filepath.IsAbs(relativeTarget) ||
strings.HasPrefix(relativeTarget, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("%w: %q", ErrOutsideRoot, relative)
}
return target, nil
}
func invalid(reason string) error {
return fmt.Errorf("%w: %s", ErrInvalidRelativePath, reason)
}
+138
View File
@@ -0,0 +1,138 @@
package safepath
import (
"errors"
"path/filepath"
"testing"
)
func TestValidateRelative(t *testing.T) {
valid := []string{
"JsonParser.exe",
"bin/JsonParser.exe",
"工具/解析器.exe",
".config/settings.json",
"name..txt",
"folder with spaces/read me.txt",
}
for _, value := range valid {
t.Run("valid_"+value, func(t *testing.T) {
if err := ValidateRelative(value); err != nil {
t.Fatalf("ValidateRelative(%q) error = %v", value, err)
}
})
}
invalid := []string{
"",
".",
"..",
"../escape.exe",
"a/../escape.exe",
"./App.exe",
"a//App.exe",
"a/",
"/App.exe",
"//server/share/App.exe",
`C:/App.exe`,
`C:App.exe`,
`bin\App.exe`,
"bad\x00name.exe",
"bad\nname.exe",
"bad<name.exe",
"bad>name.exe",
`bad"name.exe`,
"bad|name.exe",
"bad?name.exe",
"bad*name.exe",
"folder./App.exe",
"folder /App.exe",
" App.exe",
"folder/ App.exe",
"App.exe.",
"App.exe ",
"CON",
"con.txt",
"PRN.json",
"AUX",
"NUL.bin",
"NUL .bin",
"COM1",
"com9.log",
"LPT1",
"lpt9.txt",
"COM¹",
"LPT³.log",
"CLOCK$",
"CONIN$",
"CONOUT$",
}
for _, value := range invalid {
t.Run("invalid_"+value, func(t *testing.T) {
err := ValidateRelative(value)
if !errors.Is(err, ErrInvalidRelativePath) {
t.Fatalf(
"ValidateRelative(%q) error = %v, want %v",
value,
err,
ErrInvalidRelativePath,
)
}
})
}
}
func TestValidateWorkingDirectory(t *testing.T) {
for _, value := range []string{".", "bin", "资源/运行目录"} {
if err := ValidateWorkingDirectory(value); err != nil {
t.Fatalf("ValidateWorkingDirectory(%q) error = %v", value, err)
}
}
for _, value := range []string{"", "..", "bin.", "NUL"} {
if err := ValidateWorkingDirectory(value); !errors.Is(err, ErrInvalidRelativePath) {
t.Fatalf(
"ValidateWorkingDirectory(%q) error = %v, want %v",
value,
err,
ErrInvalidRelativePath,
)
}
}
}
func TestCollisionKeyUsesUnicodeSimpleFold(t *testing.T) {
tests := [][2]string{
{"App.EXE", "app.exe"},
{"K.exe", "K.exe"},
{"Σ.txt", "ς.txt"},
}
for _, values := range tests {
if CollisionKey(values[0]) != CollisionKey(values[1]) {
t.Fatalf(
"CollisionKey(%q) != CollisionKey(%q)",
values[0],
values[1],
)
}
}
}
func TestJoinUnder(t *testing.T) {
root := filepath.Join(t.TempDir(), "staging")
target, err := JoinUnder(root, "bin/App.exe")
if err != nil {
t.Fatalf("JoinUnder() error = %v", err)
}
want := filepath.Join(root, "bin", "App.exe")
if target != want {
t.Fatalf("JoinUnder() = %q, want %q", target, want)
}
for _, value := range []string{"../escape.exe", ".. /escape.exe", "/escape.exe"} {
_, err := JoinUnder(root, value)
if !errors.Is(err, ErrInvalidRelativePath) &&
!errors.Is(err, ErrOutsideRoot) {
t.Fatalf("JoinUnder(%q) error = %v", value, err)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package core exposes shared, UI-independent SoftBox functionality.
package core
// ProductName is the stable display name shared by both application targets.
const ProductName = "SoftBox"

Some files were not shown because too many files have changed in this diff Show More