Author SHA1 Message Date
ilaandClaude Fable 5 09f56478d3 Add Phase 3 review cross-check ruling
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Adjudicate Codex's review correction: concede three overstatements in
the original review (M3 loop not actually complete - InstallService has
no production assembly and T-401 launch is unbuilt; scope 'no security
defect' to T-302/T-303; O2 mischaracterized - ENOSPC during io.Copy is
misclassified as zip_corrupt with a broken error chain, escalate to P1;
O1 fix was flawed - recheck IsRunning explicitly instead of inferring
app_running from rename failure). Add finding: disk-full surfaces at
write/sync/close with three different codes, so the O2 fix must span the
whole write->sync->close sequence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:06:55 +08:00
ilaandClaude Fable 5 1ad00f8ef2 Add Phase 3 install integration review (T-301~T-303)
Code-level audit of the verified install chain: hash-before-parse and
single-file-handle TOCTOU defenses in verified_package.go, strict
app.json parse cross-checked against the signed Catalog, untrusted
download verified against Catalog Size/SHA256, mandatory non-bypassable
pre-extract disk/running checks, and a complete stable failure-code
enum. No security defect found; records five minor optimizations, the
top being the IsRunning TOCTOU (recheck before the current->backup
rename).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:44:57 +08:00
ila ae3f64c407 Add install preflight safeguards (T-303) 2026-07-18 18:14:25 +08:00
ila 449b183ca3 Define failure handling task (T-303) 2026-07-18 18:04:01 +08:00
ila 14589abb31 Integrate verified installation flow (T-302) 2026-07-18 17:55:01 +08:00
ila 6575c9ad9b Define installation integration task (T-302) 2026-07-18 17:35:11 +08:00
ilaandClaude Fable 5 a72e7b04dc Add T-606~T-614 remediation review; ignore editor workspace
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Verify at code level that the Phase 1/2 review findings were actually
closed (not just self-reported): icon cache concurrency + LRU + bounded
fetch (T-606), UI-thread icon delivery (T-607), install durability with
Windows FlushFileBuffers / POSIX dir sync (T-613), ZIP central-directory
preflight (T-612), and catalog signature cross-impl vectors (T-614).
All confirmed real. Records three residuals (R1 real power-loss
validation, R2 per-file fsync cost, R3 singleflight ctx caveat).

Also gitignore *.code-workspace (per review recommendation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:05:00 +08:00
ila 0c1b7662c6 Freeze catalog signature vectors (T-614) 2026-07-18 16:46:17 +08:00
ila 9e5f3f4840 Define catalog signature vector task (T-614) 2026-07-18 16:36:31 +08:00
ila 20596a7de4 Fence installation transaction durability (T-613) 2026-07-18 16:28:49 +08:00
ila 84befee70f Define installation durability task (T-613) 2026-07-18 16:14:12 +08:00
ila 0f69fa330e Preflight ZIP central directory metadata (T-612) 2026-07-18 15:40:44 +08:00
ila 65ff7a3f23 Define ZIP central directory preflight task (T-612) 2026-07-18 15:29:39 +08:00
ila 320b83d929 Implement unsafe icon cache diagnostics (T-611) 2026-07-18 15:18:29 +08:00
ila 6455fec811 Define unsafe icon cache diagnostics task (T-611) 2026-07-18 14:31:53 +08:00
ila e9386d26e7 Split Gio shell responsibilities (T-610) 2026-07-18 14:23:02 +08:00
ila 75b1803564 Define Gio shell responsibility split task (T-610) 2026-07-17 18:21:05 +08:00
ila 171572973b Stabilize visible item snapshots (T-609) 2026-07-17 17:31:46 +08:00
ila ed9ded2110 Define visible snapshot lifecycle task (T-609) 2026-07-17 10:49:58 +08:00
67 changed files with 6967 additions and 1645 deletions
+3
View File
@@ -30,3 +30,6 @@ gitea.env.*
# Python 本地校验缓存
__pycache__/
*.py[cod]
# 本地编辑器 workspace 配置(个人,不入库)
*.code-workspace
+1
View File
@@ -44,6 +44,7 @@
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
+11 -2
View File
@@ -9,6 +9,11 @@ import (
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
type iconFailureState struct {
Identity application.IconEventIdentity
Code application.IconFailureCode
}
// ExpectIcon records the newest request identity on the UI goroutine.
func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error {
validated, err := application.NewIconEventIdentity(
@@ -45,6 +50,7 @@ func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
return false
}
delete(shell.iconRequests, appID)
delete(shell.iconFailures, appID)
return true
}
@@ -73,7 +79,10 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
if !hasApplied || !sameIconResource(applied, identity) {
shell.ApplyIcon(identity.AppID, nil)
}
shell.iconFailures[identity.AppID] = iconEvent.ErrorCode
shell.iconFailures[identity.AppID] = iconFailureState{
Identity: identity,
Code: iconEvent.ErrorCode,
}
}
return nil
}
@@ -81,7 +90,7 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
// 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
return failure.Code, exists
}
func canonicalIconReference(reference string) string {
+158
View File
@@ -230,6 +230,164 @@ func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
}
}
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
reference := testIconReference("55")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
shell.model.Select(item.ID)
nodes := adapterContractLayout(shell, adapterContractViewport)
failure := shell.iconFailures[item.ID]
for _, want := range []string{
"图标缓存安全警告",
unsafeIconCacheMessage,
unsafeIconCacheDiagnostic(failure),
} {
if !adapterContractHasSemantic(nodes, want) {
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
}
}
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
}
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
if err := shell.ExpectIcon(unavailable); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
nodes = adapterContractLayout(shell, adapterContractViewport)
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
}
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
}
}
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
reference := testIconReference("66")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
if err := shell.ExpectIcon(first); err != nil {
t.Fatal(err)
}
firstFailure := applyTestIconFailure(
t,
shell,
first,
application.IconFailureUnsafe,
)
if got := shell.iconFailures[item.ID].Identity; got != first {
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
}
shell.SetItems([]application.CatalogListItem{item})
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnsafe {
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
}
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
if err := shell.ExpectIcon(latest); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("new request retained the previous unsafe diagnostic")
}
if err := shell.ApplyEvent(firstFailure); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("late failure restored a stale diagnostic")
}
ready, err := application.NewIconReadyEvent(
latest,
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(ready); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("ready event retained an unsafe diagnostic")
}
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
if err := shell.ExpectIcon(dpiRequest); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
t.Fatalf("stored failure DPI = %d, want 144", got)
}
newReference := testIconReference("77")
changed := item
changed.IconRef = newReference
shell.SetItems([]application.CatalogListItem{changed})
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("IconRef change retained the unsafe diagnostic")
}
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
if err := shell.ExpectIcon(canceled); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the current request")
}
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("canceled failure created an unsafe diagnostic")
}
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
if err := shell.ExpectIcon(final); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
shell.SetItems(nil)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("removed app retained the unsafe diagnostic")
}
}
func applyTestIconFailure(
t *testing.T,
shell *AppShell,
identity application.IconEventIdentity,
code application.IconFailureCode,
) application.Event {
t.Helper()
event, err := application.NewIconFailedEvent(identity, code)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(event); err != nil {
t.Fatal(err)
}
return event
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
+5 -777
View File
@@ -1,53 +1,17 @@
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
@@ -69,7 +33,7 @@ type AppShell struct {
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]application.IconFailureCode
iconFailures map[string]iconFailureState
lastRendered int
detailRendered bool
}
@@ -90,7 +54,7 @@ func NewAppShell(
iconReferences: make(map[string]string),
iconRequests: make(map[string]application.IconEventIdentity),
iconApplied: make(map[string]application.IconEventIdentity),
iconFailures: make(map[string]application.IconFailureCode),
iconFailures: make(map[string]iconFailureState),
}
shell.search.SingleLine = true
shell.SetItems(items)
@@ -118,7 +82,7 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
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))
nextFailures := make(map[string]iconFailureState, len(items))
for _, item := range items {
controls := shell.rows[item.ID]
if controls == nil {
@@ -137,7 +101,8 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference {
nextApplied[item.ID] = applied
}
if failure, exists := shell.iconFailures[item.ID]; exists {
if failure, exists := shell.iconFailures[item.ID]; exists &&
failure.Identity.Reference == reference {
nextFailures[item.ID] = failure
}
}
@@ -160,19 +125,6 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
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)
@@ -233,727 +185,3 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
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
}
}
+326
View File
@@ -0,0 +1,326 @@
package gio
import (
"fmt"
"image"
"image/color"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
)
type rowControls struct {
open widget.Clickable
}
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) 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,
)
})
}),
)
})
}
+246
View File
@@ -0,0 +1,246 @@
package gio
import (
"fmt"
"strings"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
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(func(gtx layout.Context) layout.Dimensions {
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
}),
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) layoutUnsafeIconCacheFailure(
gtx layout.Context,
theme *material.Theme,
appID string,
) layout.Dimensions {
failure, exists := shell.iconFailures[appID]
if !exists || failure.Code != application.IconFailureUnsafe ||
failure.Identity.AppID != appID {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedPanel(
gtx,
shellColors.destructive,
shellColors.surface,
unit.Dp(8),
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 {
title := material.Body1(theme, "图标缓存安全警告")
title.Color = shellColors.destructive
return title.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
diagnostic.Color = shellColors.secondary
return diagnostic.Layout(gtx)
}),
)
},
)
})
}
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
return fmt.Sprintf(
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
failure.Code,
failure.Identity.AppID,
unsafeIconCacheLocator(failure.Identity),
)
}
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
digest := strings.TrimPrefix(identity.Reference, "sha256:")
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
}
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
}
}
+152
View File
@@ -0,0 +1,152 @@
package gio
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
)
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) 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)
}),
)
}
+154
View File
@@ -0,0 +1,154 @@
package gio
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"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},
}
// 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
}
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
}
}
+11 -2
View File
@@ -9,6 +9,11 @@ import (
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
type iconFailureState struct {
Identity application.IconEventIdentity
Code application.IconFailureCode
}
// ExpectIcon records the newest request identity on the UI goroutine.
func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error {
validated, err := application.NewIconEventIdentity(
@@ -45,6 +50,7 @@ func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
return false
}
delete(shell.iconRequests, appID)
delete(shell.iconFailures, appID)
return true
}
@@ -73,7 +79,10 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
if !hasApplied || !sameIconResource(applied, identity) {
shell.ApplyIcon(identity.AppID, nil)
}
shell.iconFailures[identity.AppID] = iconEvent.ErrorCode
shell.iconFailures[identity.AppID] = iconFailureState{
Identity: identity,
Code: iconEvent.ErrorCode,
}
}
return nil
}
@@ -81,7 +90,7 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
// 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
return failure.Code, exists
}
func canonicalIconReference(reference string) string {
+158
View File
@@ -230,6 +230,164 @@ func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
}
}
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
reference := testIconReference("55")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
shell.model.Select(item.ID)
nodes := adapterContractLayout(shell, adapterContractViewport)
failure := shell.iconFailures[item.ID]
for _, want := range []string{
"图标缓存安全警告",
unsafeIconCacheMessage,
unsafeIconCacheDiagnostic(failure),
} {
if !adapterContractHasSemantic(nodes, want) {
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
}
}
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
}
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
if err := shell.ExpectIcon(unavailable); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
nodes = adapterContractLayout(shell, adapterContractViewport)
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
}
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
}
}
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
reference := testIconReference("66")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
if err := shell.ExpectIcon(first); err != nil {
t.Fatal(err)
}
firstFailure := applyTestIconFailure(
t,
shell,
first,
application.IconFailureUnsafe,
)
if got := shell.iconFailures[item.ID].Identity; got != first {
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
}
shell.SetItems([]application.CatalogListItem{item})
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnsafe {
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
}
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
if err := shell.ExpectIcon(latest); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("new request retained the previous unsafe diagnostic")
}
if err := shell.ApplyEvent(firstFailure); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("late failure restored a stale diagnostic")
}
ready, err := application.NewIconReadyEvent(
latest,
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(ready); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("ready event retained an unsafe diagnostic")
}
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
if err := shell.ExpectIcon(dpiRequest); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
t.Fatalf("stored failure DPI = %d, want 144", got)
}
newReference := testIconReference("77")
changed := item
changed.IconRef = newReference
shell.SetItems([]application.CatalogListItem{changed})
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("IconRef change retained the unsafe diagnostic")
}
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
if err := shell.ExpectIcon(canceled); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the current request")
}
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("canceled failure created an unsafe diagnostic")
}
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
if err := shell.ExpectIcon(final); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
shell.SetItems(nil)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("removed app retained the unsafe diagnostic")
}
}
func applyTestIconFailure(
t *testing.T,
shell *AppShell,
identity application.IconEventIdentity,
code application.IconFailureCode,
) application.Event {
t.Helper()
event, err := application.NewIconFailedEvent(identity, code)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(event); err != nil {
t.Fatal(err)
}
return event
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
+5 -694
View File
@@ -1,53 +1,17 @@
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
@@ -69,7 +33,7 @@ type AppShell struct {
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]application.IconFailureCode
iconFailures map[string]iconFailureState
lastRendered int
detailRendered bool
}
@@ -90,7 +54,7 @@ func NewAppShell(
iconReferences: make(map[string]string),
iconRequests: make(map[string]application.IconEventIdentity),
iconApplied: make(map[string]application.IconEventIdentity),
iconFailures: make(map[string]application.IconFailureCode),
iconFailures: make(map[string]iconFailureState),
}
shell.search.SingleLine = true
shell.SetItems(items)
@@ -118,7 +82,7 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
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))
nextFailures := make(map[string]iconFailureState, len(items))
for _, item := range items {
controls := shell.rows[item.ID]
if controls == nil {
@@ -137,7 +101,8 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference {
nextApplied[item.ID] = applied
}
if failure, exists := shell.iconFailures[item.ID]; exists {
if failure, exists := shell.iconFailures[item.ID]; exists &&
failure.Identity.Reference == reference {
nextFailures[item.ID] = failure
}
}
@@ -160,19 +125,6 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
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)
@@ -237,644 +189,3 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
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
}
}
+299
View File
@@ -0,0 +1,299 @@
package gio
import (
"fmt"
"image"
"image/color"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
)
type rowControls struct {
open widget.Clickable
}
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) 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,
)
})
}),
)
})
}
+217
View File
@@ -0,0 +1,217 @@
package gio
import (
"fmt"
"strings"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
)
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
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(func(gtx layout.Context) layout.Dimensions {
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
}),
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) layoutUnsafeIconCacheFailure(
gtx layout.Context,
theme *material.Theme,
appID string,
) layout.Dimensions {
failure, exists := shell.iconFailures[appID]
if !exists || failure.Code != application.IconFailureUnsafe ||
failure.Identity.AppID != appID {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedPanel(
gtx,
shellColors.destructive,
shellColors.surface,
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 {
title := material.Body1(theme, "图标缓存安全警告")
title.Color = shellColors.destructive
return title.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
diagnostic.Color = shellColors.secondary
return diagnostic.Layout(gtx)
}),
)
},
)
})
}
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
return fmt.Sprintf(
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
failure.Code,
failure.Identity.AppID,
unsafeIconCacheLocator(failure.Identity),
)
}
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
digest := strings.TrimPrefix(identity.Reference, "sha256:")
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
}
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
}
}
+124
View File
@@ -0,0 +1,124 @@
package gio
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
)
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) 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)
}
+154
View File
@@ -0,0 +1,154 @@
package gio
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"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},
}
// 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
}
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
}
}
+7 -3
View File
@@ -109,7 +109,10 @@ func (model *CatalogListModel) ResetFilters() {
model.refilter()
}
// VisibleItems returns the current immutable-by-convention snapshot.
// VisibleItems returns the current read-only snapshot generation without copying.
// The snapshot remains stable after later model changes. Callers must not modify
// its elements, nested Tags, or capacity; CatalogListModel is single-owner and
// does not support concurrent reads and writes.
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
return model.visible
}
@@ -162,7 +165,7 @@ func (view CatalogView) Valid() bool {
}
func (model *CatalogListModel) refilter() {
model.visible = model.visible[:0]
visible := make([]CatalogListItem, 0, len(model.items))
for _, item := range model.items {
if model.category != "" && item.Category != model.category {
continue
@@ -173,8 +176,9 @@ func (model *CatalogListModel) refilter() {
if model.query != "" && !matchesQuery(item, model.query) {
continue
}
model.visible = append(model.visible, item)
visible = append(visible, item)
}
model.visible = visible
}
func matchesView(item CatalogListItem, view CatalogView) bool {
+148
View File
@@ -1,11 +1,14 @@
package application
import (
"reflect"
"testing"
"softbox.local/core/domain"
)
var visibleSnapshotSink []CatalogListItem
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{
@@ -98,6 +101,151 @@ func TestCatalogListModelCategoriesAndReset(t *testing.T) {
}
}
func TestCatalogListModelVisibleSnapshotsSurviveModelChanges(t *testing.T) {
tests := []struct {
name string
prepare func(*CatalogListModel)
mutate func(*CatalogListModel)
}{
{
name: "query",
mutate: func(model *CatalogListModel) { model.SetQuery("image") },
},
{
name: "category",
mutate: func(model *CatalogListModel) { model.SetCategory("图像") },
},
{
name: "view",
mutate: func(model *CatalogListModel) { model.SetView(CatalogViewUpdates) },
},
{
name: "reset",
prepare: func(model *CatalogListModel) {
model.SetQuery("image")
},
mutate: func(model *CatalogListModel) { model.ResetFilters() },
},
{
name: "items",
mutate: func(model *CatalogListModel) {
model.SetItems([]CatalogListItem{
{ID: "new-app", Name: "New", Category: "其他", Tags: []string{"new"}},
})
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
model := NewCatalogListModel(catalogSnapshotFixture())
if test.prepare != nil {
test.prepare(model)
}
previous := model.VisibleItems()
if len(previous) == 0 {
t.Fatal("test setup produced an empty previous generation")
}
wantPrevious := cloneSnapshotForTest(previous)
test.mutate(model)
if !reflect.DeepEqual(previous, wantPrevious) {
t.Fatalf("previous generation changed:\n got: %#v\nwant: %#v", previous, wantPrevious)
}
current := model.VisibleItems()
if len(current) == 0 {
t.Fatal("test mutation produced an empty current generation")
}
if &previous[0] == &current[0] {
t.Fatal("current generation reused the previous backing array")
}
})
}
}
func TestCatalogListModelVisibleItemsDoesNotCopyWithinGeneration(t *testing.T) {
model := NewCatalogListModel(catalogSnapshotFixture())
first := model.VisibleItems()
second := model.VisibleItems()
if len(first) == 0 || len(second) == 0 {
t.Fatal("test setup produced an empty generation")
}
if &first[0] != &second[0] {
t.Fatal("repeated VisibleItems calls copied the current generation")
}
model.SetQuery(" ")
unchanged := model.VisibleItems()
if &first[0] != &unchanged[0] {
t.Fatal("no-op model update published a new generation")
}
if allocations := testing.AllocsPerRun(100, func() {
visibleSnapshotSink = model.VisibleItems()
}); allocations != 0 {
t.Fatalf("VisibleItems allocations per read = %v, want 0", allocations)
}
model.SetQuery("image")
changed := model.VisibleItems()
if len(changed) == 0 {
t.Fatal("changed generation is empty")
}
if &first[0] == &changed[0] {
t.Fatal("actual model update did not publish a new generation")
}
}
func TestCatalogListModelVisibleSnapshotsCoverEmptyAndRestore(t *testing.T) {
model := NewCatalogListModel(nil)
if visible := model.VisibleItems(); len(visible) != 0 {
t.Fatalf("empty catalog visible items = %#v", visible)
}
model.SetItems(catalogSnapshotFixture())
full := model.VisibleItems()
wantFull := cloneSnapshotForTest(full)
model.SetQuery("missing-app")
if visible := model.VisibleItems(); len(visible) != 0 {
t.Fatalf("no-match visible items = %#v", visible)
}
if !reflect.DeepEqual(full, wantFull) {
t.Fatalf("full generation changed after empty filter:\n got: %#v\nwant: %#v", full, wantFull)
}
model.ResetFilters()
assertVisibleIDs(t, model, "json-parser", "image-tool", "log-viewer")
if !reflect.DeepEqual(full, wantFull) {
t.Fatalf("full generation changed after reset:\n got: %#v\nwant: %#v", full, wantFull)
}
}
func catalogSnapshotFixture() []CatalogListItem {
return []CatalogListItem{
{
ID: "json-parser", Name: "JSON Parser", Category: "开发",
Tags: []string{"json", "format"}, Status: domain.StatusInstalled, Installed: true,
},
{
ID: "image-tool", Name: "Image Tool", Category: "图像",
Tags: []string{"png", "compress"}, Status: domain.StatusUpdateAvailable, Installed: true,
},
{
ID: "log-viewer", Name: "Log Viewer", Category: "开发",
Tags: []string{"log", "diagnostic"}, Status: domain.StatusNotInstalled,
},
}
}
func cloneSnapshotForTest(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 assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
t.Helper()
visible := model.VisibleItems()
+371
View File
@@ -0,0 +1,371 @@
package install
import (
"errors"
"fmt"
"math"
"path/filepath"
"softbox.local/core/catalog"
"softbox.local/core/installer"
"softbox.local/core/storage"
)
var (
ErrInstallServiceConfig = errors.New("invalid install service configuration")
ErrInstallRequestInvalid = errors.New("invalid install request")
ErrInstallRecordWrite = errors.New("write installed app record")
ErrDiskSpaceInsufficient = errors.New("insufficient disk space for staging")
ErrDiskSpaceCheck = errors.New("disk space check failed")
ErrTargetRunning = errors.New("installed app is running")
ErrTargetStateCheck = errors.New("target state check failed")
)
const StagingDiskReserveBytes int64 = 64 * 1024 * 1024
// InstallStage makes the security-sensitive installation path observable to a
// background caller without giving the Gio layout any filesystem work.
type InstallStage string
const (
InstallStageVerify InstallStage = "verify"
InstallStageManifest InstallStage = "manifest"
InstallStagePreflight InstallStage = "preflight"
InstallStageExtract InstallStage = "extract"
InstallStageRecover InstallStage = "recover"
InstallStageSwitch InstallStage = "switch"
InstallStageHealth InstallStage = "health"
InstallStageRecord InstallStage = "record"
InstallStageRollback InstallStage = "rollback"
)
// FailureCode is the stable, non-localized result of an installation
// attempt. UI code may localize this code but must not display raw errors.
type FailureCode string
const (
FailureCodeHashMismatch FailureCode = "hash_mismatch"
FailureCodeZIPPathEscape FailureCode = "zip_path_escape"
FailureCodeZIPCorrupt FailureCode = "zip_corrupt"
FailureCodePackageInvalid FailureCode = "package_invalid"
FailureCodeDiskFull FailureCode = "disk_full"
FailureCodeDiskCheckFailed FailureCode = "disk_check_failed"
FailureCodeAppRunning FailureCode = "app_running"
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
FailureCodeInstallFailed FailureCode = "install_failed"
)
// InstallError preserves a stable stage and its underlying cause.
type InstallError struct {
Stage InstallStage
Code FailureCode
Err error
}
func (err *InstallError) Error() string {
return fmt.Sprintf("install %s (%s): %v", err.Stage, err.Code, err.Err)
}
func (err *InstallError) Unwrap() error {
return err.Err
}
// InstallRecordStore provides the app-root and installed-app record boundary
// needed by an installation transaction.
type InstallRecordStore interface {
EnsureAppRoot(appID string) (string, error)
Write(record storage.InstalledApp) error
}
// DiskSpaceChecker reports bytes currently available on the volume that
// contains appRoot. Platform-specific implementations stay outside core.
type DiskSpaceChecker interface {
AvailableBytes(appRoot string) (int64, error)
}
// TargetStateChecker reports whether the verified current entrypoint is still
// running. It never starts, waits for, or terminates a process.
type TargetStateChecker interface {
IsRunning(appID string, entrypointPath string) (bool, error)
}
// InstallRequest joins an untrusted completed download with the trusted
// Catalog selection that describes it.
type InstallRequest struct {
Entry catalog.Entry
Architecture catalog.Architecture
DownloadPath string
}
// InstallResult describes an installed version after the switch commits.
type InstallResult struct {
AppID string
Version string
EntrypointPath string
Recovery installer.RecoveryResult
}
// InstallServiceConfig makes all external installation dependencies explicit.
// Disk and target-state checks are mandatory so no caller can silently bypass
// the pre-extract safety boundary.
type InstallServiceConfig struct {
Extractor installer.Extractor
Records InstallRecordStore
Health installer.HealthCheck
DiskSpace DiskSpaceChecker
TargetState TargetStateChecker
}
// InstallService implements the core-only verified package installation use
// case. The caller must supply entries produced by catalog.Client.
type InstallService struct {
extractor installer.Extractor
records InstallRecordStore
health installer.HealthCheck
diskSpace DiskSpaceChecker
targetState TargetStateChecker
}
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
if config.Records == nil {
return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig)
}
if config.Health == nil {
return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired)
}
if config.DiskSpace == nil {
return nil, fmt.Errorf("%w: disk space checker is required", ErrInstallServiceConfig)
}
if config.TargetState == nil {
return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig)
}
return &InstallService{
extractor: config.Extractor,
records: config.Records,
health: config.Health,
diskSpace: config.DiskSpace,
targetState: config.TargetState,
}, nil
}
// Install verifies and extracts one completed download, then atomically
// switches staging into current. Metadata is written inside the Switcher
// health phase so a write failure follows the same rollback path as health.
func (service *InstallService) Install(request InstallRequest) (InstallResult, error) {
expectation, record, err := resolveInstallRequest(request)
if err != nil {
return InstallResult{}, installError(InstallStageVerify, err)
}
appRoot, err := service.records.EnsureAppRoot(record.ID)
if err != nil {
return InstallResult{}, installError(InstallStageRecover, err)
}
recovery, err := installer.Recover(appRoot)
if err != nil {
return InstallResult{}, installError(InstallStageRecover, err)
}
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
request.DownloadPath,
filepath.Join(appRoot, "staging"),
expectation,
service.preExtractCheck(appRoot, record.ID),
)
if err != nil {
return InstallResult{}, installError(stageForPackageError(err), err)
}
record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles))
for _, file := range extracted.PayloadFiles {
record.Files = append(record.Files, storage.InstalledFile{
Path: file.Path,
Size: file.Size,
SHA256: file.SHA256,
})
}
var recordWriteErr error
switcher := installer.NewSwitcher(func(currentPath string) error {
if err := service.health(currentPath); err != nil {
return err
}
if err := service.records.Write(record); err != nil {
recordWriteErr = err
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
}
return nil
})
if err := switcher.Switch(appRoot); err != nil {
return InstallResult{}, installError(stageForSwitchError(err, recordWriteErr), err)
}
return InstallResult{
AppID: record.ID,
Version: record.Version,
EntrypointPath: filepath.Join(appRoot, "current", expectation.App.Entrypoint),
Recovery: recovery,
}, nil
}
func (service *InstallService) preExtractCheck(
appRoot string,
appID string,
) installer.PreExtractCheck {
return func(verified installer.VerifiedPackage) error {
required, err := requiredStagingBytes(verified.PayloadBytes)
if err != nil {
return err
}
available, err := service.diskSpace.AvailableBytes(appRoot)
if err != nil {
return fmt.Errorf("%w: %w", ErrDiskSpaceCheck, err)
}
if available < 0 {
return fmt.Errorf("%w: negative available bytes", ErrDiskSpaceCheck)
}
if available < required {
return fmt.Errorf("%w: available=%d required=%d", ErrDiskSpaceInsufficient, available, required)
}
running, err := service.targetState.IsRunning(
appID,
filepath.Join(appRoot, "current", verified.Entrypoint),
)
if err != nil {
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
}
if running {
return ErrTargetRunning
}
return nil
}
}
func requiredStagingBytes(payloadBytes int64) (int64, error) {
if payloadBytes < 0 || payloadBytes > math.MaxInt64-StagingDiskReserveBytes {
return 0, fmt.Errorf("%w: invalid payload size", ErrDiskSpaceCheck)
}
return payloadBytes + StagingDiskReserveBytes, nil
}
func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation, storage.InstalledApp, error) {
if request.DownloadPath == "" {
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
"%w: completed download path is empty",
ErrInstallRequestInvalid,
)
}
if !request.Entry.Installable || request.Entry.Package == nil {
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
"%w: Catalog entry is not installable",
ErrInstallRequestInvalid,
)
}
if request.Architecture != catalog.Architecture386 && request.Architecture != catalog.ArchitectureAMD64 {
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
"%w: unsupported architecture %q",
ErrInstallRequestInvalid,
request.Architecture,
)
}
publishedPackage, exists := request.Entry.App.Packages[request.Architecture]
if !exists || publishedPackage != *request.Entry.Package {
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
"%w: selected package does not match app architecture",
ErrInstallRequestInvalid,
)
}
app := request.Entry.App
return installer.PackageExpectation{
Size: publishedPackage.Size,
SHA256: publishedPackage.SHA256,
App: installer.AppExpectation{
ID: app.ID,
Version: app.Version,
Channel: string(app.Channel),
MinOS: string(app.MinOS),
Architecture: string(request.Architecture),
Entrypoint: app.EntryEXE,
RequiresAdmin: app.RequiresAdmin,
},
}, storage.InstalledApp{
SchemaVersion: 1,
ID: app.ID,
Version: app.Version,
Architecture: string(request.Architecture),
Channel: string(app.Channel),
Files: []storage.InstalledFile{},
}, nil
}
func installError(stage InstallStage, err error) error {
return &InstallError{Stage: stage, Code: failureCodeFor(err), Err: err}
}
func stageForPackageError(err error) InstallStage {
var packageErr *installer.PackageError
if errors.As(err, &packageErr) {
switch packageErr.Stage {
case installer.PackageStageManifest:
return InstallStageManifest
case installer.PackageStagePreflight:
return InstallStagePreflight
case installer.PackageStageExtract:
return InstallStageExtract
}
}
return InstallStageVerify
}
func failureCodeFor(err error) FailureCode {
switch {
case errors.Is(err, ErrDiskSpaceInsufficient):
return FailureCodeDiskFull
case errors.Is(err, ErrDiskSpaceCheck):
return FailureCodeDiskCheckFailed
case errors.Is(err, ErrTargetRunning):
return FailureCodeAppRunning
case errors.Is(err, ErrTargetStateCheck):
return FailureCodeTargetStateUnavailable
case errors.Is(err, installer.ErrPackageHashMismatch),
errors.Is(err, installer.ErrArchiveSizeMismatch):
return FailureCodeHashMismatch
case errors.Is(err, installer.ErrPathEscape),
errors.Is(err, installer.ErrEntrypointInvalid):
return FailureCodeZIPPathEscape
case errors.Is(err, installer.ErrPackageExpectationInvalid),
errors.Is(err, installer.ErrAppManifestTooLarge),
errors.Is(err, installer.ErrAppManifestInvalid),
errors.Is(err, installer.ErrAppManifestMissing),
errors.Is(err, installer.ErrPackageIdentityMismatch),
errors.Is(err, installer.ErrEntrypointMissing),
errors.Is(err, installer.ErrUnexpectedEntry),
errors.Is(err, installer.ErrUnsupportedEntry),
errors.Is(err, installer.ErrEncryptedEntry):
return FailureCodePackageInvalid
case errors.Is(err, installer.ErrInvalidArchive),
errors.Is(err, installer.ErrArchiveCorrupt),
errors.Is(err, installer.ErrArchiveTooLarge),
errors.Is(err, installer.ErrCentralDirectoryTooLarge),
errors.Is(err, installer.ErrTooManyEntries),
errors.Is(err, installer.ErrExpandedTooLarge),
errors.Is(err, installer.ErrCompressionRatio),
errors.Is(err, installer.ErrDuplicateEntry):
return FailureCodeZIPCorrupt
default:
return FailureCodeInstallFailed
}
}
func stageForSwitchError(err error, recordWriteErr error) InstallStage {
if errors.Is(err, installer.ErrRollbackFailed) {
return InstallStageRollback
}
if recordWriteErr != nil {
return InstallStageRecord
}
if errors.Is(err, installer.ErrHealthCheckFailed) {
return InstallStageHealth
}
return InstallStageSwitch
}
+728
View File
@@ -0,0 +1,728 @@
package install
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"testing"
"softbox.local/core/catalog"
"softbox.local/core/installer"
"softbox.local/core/storage"
)
func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
service := newInstallService(t, store, func(currentPath string) error {
_, err := os.Stat(filepath.Join(currentPath, "bin", "App.exe"))
return err
})
result, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.2.3"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if err != nil {
t.Fatalf("Install() error = %v", err)
}
if result.AppID != "test-app" || result.Version != "1.2.3" || result.Recovery.Action != installer.RecoveryNone {
t.Fatalf("result = %#v", result)
}
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "new executable" {
t.Fatalf("current entrypoint = %q", got)
}
record, found, err := store.Read("test-app")
if err != nil || !found {
t.Fatalf("Read() found=%t err=%v", found, err)
}
if record.Version != "1.2.3" || len(record.Files) != 2 {
t.Fatalf("record = %#v", record)
}
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
t.Fatalf("record first file = %#v", record.Files[0])
}
hash := sha256.Sum256([]byte("new executable"))
if record.Files[0].SHA256 != hex.EncodeToString(hash[:]) {
t.Fatalf("record first hash = %q", record.Files[0].SHA256)
}
}
func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
tests := []struct {
name string
entry catalog.Entry
wantErr error
wantCode FailureCode
}{
{
name: "selected package differs from architecture package",
entry: func() catalog.Entry {
entry := installEntry(publishedPackage, "1.2.3")
forged := publishedPackage
forged.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"
entry.Package = &forged
return entry
}(),
wantErr: ErrInstallRequestInvalid,
wantCode: FailureCodeInstallFailed,
},
{
name: "download hash differs from Catalog",
entry: func() catalog.Entry {
entry := installEntry(publishedPackage, "1.2.3")
entry.App.Packages[catalog.ArchitectureAMD64] = catalog.Package{
Size: publishedPackage.Size,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
Signature: publishedPackage.Signature,
URL: publishedPackage.URL,
}
*entry.Package = entry.App.Packages[catalog.ArchitectureAMD64]
return entry
}(),
wantErr: installer.ErrPackageHashMismatch,
wantCode: FailureCodeHashMismatch,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
service := newInstallService(t, store, func(string) error { return nil })
_, err := service.Install(InstallRequest{
Entry: test.entry,
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
}
if stage := installErrorStage(t, err); stage != InstallStageVerify {
t.Fatalf("stage = %q, want %q", stage, InstallStageVerify)
}
if code := installErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) {
t.Fatalf("rejected install left staging, stat error = %v", statErr)
}
})
}
}
func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
tests := []struct {
name string
service func(t *testing.T) *InstallService
wantStage InstallStage
wantErr error
}{
{
name: "health failure",
service: func(t *testing.T) *InstallService {
return newInstallService(t, store, func(string) error { return errors.New("health failed") })
},
wantStage: InstallStageHealth,
wantErr: installer.ErrHealthCheckFailed,
},
{
name: "record write failure",
service: func(t *testing.T) *InstallService {
return newInstallService(t, &failingRecordStore{
InstalledAppStore: store,
writeErr: errors.New("record disk error"),
}, func(string) error { return nil })
},
wantStage: InstallStageRecord,
wantErr: ErrInstallRecordWrite,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
_, err := test.service(t).Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
}
if stage := installErrorStage(t, err); stage != test.wantStage {
t.Fatalf("stage = %q, want %q", stage, test.wantStage)
}
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current entrypoint after failure = %q", got)
}
record, found, readErr := store.Read("test-app")
if readErr != nil || !found || record.Version != "1.0.0" {
t.Fatalf("record after failure found=%t record=%#v err=%v", found, record, readErr)
}
})
}
}
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
extractor, err := installer.NewExtractor(installTestLimits())
if err != nil {
t.Fatalf("NewExtractor() error = %v", err)
}
store := storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
config := InstallServiceConfig{
Extractor: extractor,
Records: store,
Health: func(string) error { return nil },
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes, nil
}),
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
}
tests := []struct {
name string
modify func(*InstallServiceConfig)
}{
{
name: "disk space checker",
modify: func(config *InstallServiceConfig) {
config.DiskSpace = nil
},
},
{
name: "target state checker",
modify: func(config *InstallServiceConfig) {
config.TargetState = nil
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := config
test.modify(&candidate)
if _, err := NewInstallService(candidate); !errors.Is(err, ErrInstallServiceConfig) {
t.Fatalf("NewInstallService() error = %v, want %v", err, ErrInstallServiceConfig)
}
})
}
}
func TestFailureCodeForPackageFailures(t *testing.T) {
tests := []struct {
err error
code FailureCode
}{
{err: installer.ErrPackageHashMismatch, code: FailureCodeHashMismatch},
{err: installer.ErrPathEscape, code: FailureCodeZIPPathEscape},
{err: installer.ErrArchiveCorrupt, code: FailureCodeZIPCorrupt},
{err: installer.ErrAppManifestInvalid, code: FailureCodePackageInvalid},
{err: ErrInstallRequestInvalid, code: FailureCodeInstallFailed},
}
for _, test := range tests {
if got := failureCodeFor(test.err); got != test.code {
t.Fatalf("failureCodeFor(%v) = %q, want %q", test.err, got, test.code)
}
}
}
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
var diskRoot string
var targetAppID, targetEntrypoint string
service := newInstallServiceWithCheckers(
t,
store,
func(string) error { return nil },
diskSpaceCheckerFunc(func(appRoot string) (int64, error) {
diskRoot = appRoot
return required, nil
}),
targetStateCheckerFunc(func(appID, entrypointPath string) (bool, error) {
targetAppID = appID
targetEntrypoint = entrypointPath
return false, nil
}),
)
if _, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.2.3"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
}); err != nil {
t.Fatalf("Install() error = %v", err)
}
appRoot := filepath.Join(appsRoot, "test-app")
if diskRoot != appRoot {
t.Fatalf("disk check root = %q, want %q", diskRoot, appRoot)
}
if targetAppID != "test-app" || targetEntrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
t.Fatalf("target check = (%q, %q)", targetAppID, targetEntrypoint)
}
}
func TestInstallServiceInsufficientDiskLeavesNoCurrent(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
service := newInstallServiceWithCheckers(
t,
store,
func(string) error { return nil },
diskSpaceCheckerFunc(func(string) (int64, error) {
return 0, nil
}),
targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
)
_, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.2.3"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, ErrDiskSpaceInsufficient) {
t.Fatalf("Install() error = %v, want %v", err, ErrDiskSpaceInsufficient)
}
if code := installErrorCode(t, err); code != FailureCodeDiskFull {
t.Fatalf("code = %q, want %q", code, FailureCodeDiskFull)
}
appRoot := filepath.Join(appsRoot, "test-app")
for _, managed := range []string{"staging", "current"} {
if _, statErr := os.Stat(filepath.Join(appRoot, managed)); !os.IsNotExist(statErr) {
t.Fatalf("first install left %s, stat error = %v", managed, statErr)
}
}
}
func TestInstallServicePreflightFailuresPreserveExistingVersion(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
diskProbeErr := errors.New("disk probe unavailable")
targetProbeErr := errors.New("target state unavailable")
tests := []struct {
name string
disk DiskSpaceChecker
target TargetStateChecker
wantErr error
wantCode FailureCode
}{
{
name: "insufficient disk space",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return required - 1, nil
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
wantErr: ErrDiskSpaceInsufficient,
wantCode: FailureCodeDiskFull,
},
{
name: "disk capacity check fails",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return 0, diskProbeErr
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
wantErr: diskProbeErr,
wantCode: FailureCodeDiskCheckFailed,
},
{
name: "current app is running",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return required, nil
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return true, nil
}),
wantErr: ErrTargetRunning,
wantCode: FailureCodeAppRunning,
},
{
name: "target state check fails",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return required, nil
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, targetProbeErr
}),
wantErr: targetProbeErr,
wantCode: FailureCodeTargetStateUnavailable,
},
}
appRoot := filepath.Join(appsRoot, "test-app")
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newInstallServiceWithCheckers(t, store, func(string) error { return nil }, test.disk, test.target)
_, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
}
if stage := installErrorStage(t, err); stage != InstallStagePreflight {
t.Fatalf("stage = %q, want %q", stage, InstallStagePreflight)
}
if code := installErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current entrypoint after failure = %q", got)
}
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
t.Fatal("installed-app record changed after preflight failure")
}
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
}
})
}
}
func TestInstallServiceReportsCorruptPackageWithoutReplacingCurrent(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
corruptInstallPackageEntry(t, archivePath, "payload/bin/App.exe")
document, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read corrupted archive: %v", err)
}
hash := sha256.Sum256(document)
publishedPackage.Size = int64(len(document))
publishedPackage.SHA256 = hex.EncodeToString(hash[:])
service := newInstallService(t, store, func(string) error { return nil })
_, err = service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, installer.ErrArchiveCorrupt) {
t.Fatalf("Install() error = %v, want %v", err, installer.ErrArchiveCorrupt)
}
if stage := installErrorStage(t, err); stage != InstallStageExtract {
t.Fatalf("stage = %q, want %q", stage, InstallStageExtract)
}
if code := installErrorCode(t, err); code != FailureCodeZIPCorrupt {
t.Fatalf("code = %q, want %q", code, FailureCodeZIPCorrupt)
}
appRoot := filepath.Join(appsRoot, "test-app")
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current entrypoint after corruption = %q", got)
}
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
t.Fatalf("corrupt package left staging, stat error = %v", statErr)
}
}
func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
appRoot, err := store.EnsureAppRoot("test-app")
if err != nil {
t.Fatalf("EnsureAppRoot() error = %v", err)
}
if err := os.MkdirAll(filepath.Join(appRoot, "current"), 0o700); err != nil {
t.Fatalf("create current: %v", err)
}
if err := os.MkdirAll(filepath.Join(appRoot, "staging"), 0o700); err != nil {
t.Fatalf("create stale staging: %v", err)
}
if err := os.WriteFile(
filepath.Join(appRoot, "install-transaction.json"),
[]byte(`{"schema_version":1,"phase":"prepared","had_current":true}`),
0o600,
); err != nil {
t.Fatalf("write transaction: %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
service := newInstallService(t, store, func(string) error { return nil })
result, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.2.3"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if err != nil {
t.Fatalf("Install() error = %v", err)
}
if result.Recovery.Action != installer.RecoveryAborted {
t.Fatalf("recovery action = %q, want %q", result.Recovery.Action, installer.RecoveryAborted)
}
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "new executable" {
t.Fatalf("current entrypoint = %q", got)
}
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
t.Fatalf("staging remains, stat error = %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(appRoot, "install-transaction.json")); !os.IsNotExist(statErr) {
t.Fatalf("transaction remains, stat error = %v", statErr)
}
}
type failingRecordStore struct {
*storage.InstalledAppStore
writeErr error
}
func (store *failingRecordStore) Write(storage.InstalledApp) error {
return store.writeErr
}
func newInstallService(
t *testing.T,
store InstallRecordStore,
health installer.HealthCheck,
) *InstallService {
return newInstallServiceWithCheckers(
t,
store,
health,
diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes + 64*1024, nil
}),
targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
)
}
func newInstallServiceWithCheckers(
t *testing.T,
store InstallRecordStore,
health installer.HealthCheck,
diskSpace DiskSpaceChecker,
targetState TargetStateChecker,
) *InstallService {
t.Helper()
extractor, err := installer.NewExtractor(installTestLimits())
if err != nil {
t.Fatalf("NewExtractor() error = %v", err)
}
service, err := NewInstallService(InstallServiceConfig{
Extractor: extractor,
Records: store,
Health: health,
DiskSpace: diskSpace,
TargetState: targetState,
})
if err != nil {
t.Fatalf("NewInstallService() error = %v", err)
}
return service
}
type diskSpaceCheckerFunc func(string) (int64, error)
func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error) {
return check(appRoot)
}
type targetStateCheckerFunc func(string, string) (bool, error)
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
return check(appID, entrypointPath)
}
func installTestLimits() installer.Limits {
return installer.Limits{
MaxEntries: 20,
MaxArchiveBytes: 64 * 1024,
MaxCentralDirectoryBytes: 4 * 1024,
MaxUncompressedBytes: 64 * 1024,
MaxCompressionRatio: 100,
}
}
func installEntry(publishedPackage catalog.Package, version string) catalog.Entry {
selectedPackage := publishedPackage
return catalog.Entry{
App: catalog.App{
ID: "test-app",
Version: version,
Channel: catalog.ReleaseStable,
Status: catalog.CatalogStatusActive,
MinOS: catalog.Windows10,
Architectures: []catalog.Architecture{catalog.ArchitectureAMD64},
EntryEXE: "bin/App.exe",
Packages: map[catalog.Architecture]catalog.Package{
catalog.ArchitectureAMD64: publishedPackage,
},
},
Package: &selectedPackage,
Installable: true,
}
}
func writeInstallPackage(t *testing.T, version, executable string) (string, catalog.Package) {
t.Helper()
path := filepath.Join(t.TempDir(), "package.download")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create package: %v", err)
}
writer := zip.NewWriter(file)
entries := []struct {
name string
body []byte
mode os.FileMode
}{
{name: "app.json", body: installManifest(version)},
{name: "payload/bin/App.exe", body: []byte(executable), mode: 0o755},
{name: "payload/readme.txt", body: []byte("readme")},
}
for _, entry := range entries {
header := &zip.FileHeader{Name: entry.name}
mode := entry.mode
if mode == 0 {
mode = 0o600
}
header.SetMode(mode)
part, err := writer.CreateHeader(header)
if err != nil {
t.Fatalf("create ZIP entry: %v", err)
}
if _, err := part.Write(entry.body); err != nil {
t.Fatalf("write ZIP entry: %v", 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 package: %v", err)
}
document, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read package: %v", err)
}
hash := sha256.Sum256(document)
return path, catalog.Package{
URL: "https://download.invalid/test-app.zip",
Size: int64(len(document)),
SHA256: hex.EncodeToString(hash[:]),
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
}
}
func installManifest(version string) []byte {
return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"bin/App.exe","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`)
}
func installErrorStage(t *testing.T, err error) InstallStage {
t.Helper()
var installErr *InstallError
if !errors.As(err, &installErr) {
t.Fatalf("error %v is not InstallError", err)
}
return installErr.Stage
}
func installErrorCode(t *testing.T, err error) FailureCode {
t.Helper()
var installErr *InstallError
if !errors.As(err, &installErr) {
t.Fatalf("error %v is not InstallError", err)
}
return installErr.Code
}
func corruptInstallPackageEntry(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 {
continue
}
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)
}
document, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP for corruption: %v", err)
}
document[offset] ^= 0xff
if err := os.WriteFile(archivePath, document, 0o600); err != nil {
t.Fatalf("write corrupted ZIP: %v", err)
}
}
func mustReadFile(t *testing.T, path string) string {
t.Helper()
document, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(document)
}
+76 -1
View File
@@ -10,12 +10,15 @@ import (
"unicode/utf8"
)
var integerJSONNumber = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
var integerJSONNumber = regexp.MustCompile(`^(0|[1-9][0-9]*|-[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)
}
if err := validateJSONStringSurrogates(data); err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
@@ -33,6 +36,78 @@ func parseRestrictedJSON(data []byte) (any, error) {
return value, nil
}
func validateJSONStringSurrogates(data []byte) error {
for index := 0; index < len(data); index++ {
if data[index] != '"' {
continue
}
next, err := scanJSONStringSurrogates(data, index)
if err != nil {
return err
}
index = next - 1
}
return nil
}
func scanJSONStringSurrogates(data []byte, start int) (int, error) {
for index := start + 1; index < len(data); index++ {
switch data[index] {
case '"':
return index + 1, nil
case '\\':
if index+1 >= len(data) {
return 0, fmt.Errorf("%w: incomplete string escape", ErrInvalidDocument)
}
if data[index+1] != 'u' {
index++
continue
}
codeUnit, ok := decodeJSONHexCodeUnit(data, index+2)
if !ok {
return 0, fmt.Errorf("%w: invalid unicode escape", ErrInvalidDocument)
}
switch {
case codeUnit >= 0xd800 && codeUnit <= 0xdbff:
if index+7 >= len(data) || data[index+6] != '\\' || data[index+7] != 'u' {
return 0, fmt.Errorf("%w: high surrogate is not paired", ErrInvalidDocument)
}
lowSurrogate, ok := decodeJSONHexCodeUnit(data, index+8)
if !ok || lowSurrogate < 0xdc00 || lowSurrogate > 0xdfff {
return 0, fmt.Errorf("%w: high surrogate is not followed by a low surrogate", ErrInvalidDocument)
}
index += 11
case codeUnit >= 0xdc00 && codeUnit <= 0xdfff:
return 0, fmt.Errorf("%w: low surrogate has no high surrogate", ErrInvalidDocument)
default:
index += 5
}
}
}
return 0, fmt.Errorf("%w: unterminated string", ErrInvalidDocument)
}
func decodeJSONHexCodeUnit(data []byte, start int) (uint16, bool) {
if start+4 > len(data) {
return 0, false
}
var value uint16
for _, digit := range data[start : start+4] {
value <<= 4
switch {
case digit >= '0' && digit <= '9':
value |= uint16(digit - '0')
case digit >= 'a' && digit <= 'f':
value |= uint16(digit-'a') + 10
case digit >= 'A' && digit <= 'F':
value |= uint16(digit-'A') + 10
default:
return 0, false
}
}
return value, true
}
func decodeJSONValue(decoder *json.Decoder) (any, error) {
token, err := decoder.Token()
if err != nil {
+171
View File
@@ -0,0 +1,171 @@
package catalog
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"testing"
)
type canonicalVectorCorpus struct {
SchemaVersion int `json:"schema_version"`
PublicKeyBase64 string `json:"public_key_base64"`
Vectors []canonicalVector `json:"vectors"`
}
type canonicalVector struct {
Name string `json:"name"`
Document string `json:"document"`
SignedPayloadBase64 string `json:"signed_payload_base64"`
Signature string `json:"signature"`
WantError string `json:"want_error"`
}
func TestVerifierCanonicalVectors(t *testing.T) {
corpus := readCanonicalVectorCorpus(t)
publicKey, err := base64.StdEncoding.DecodeString(corpus.PublicKeyBase64)
if err != nil {
t.Fatalf("decode corpus public key: %v", err)
}
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
for _, vector := range corpus.Vectors {
vector := vector
t.Run(vector.Name, func(t *testing.T) {
verified, err := verifier.Verify([]byte(vector.Document))
if vector.WantError != "" {
want := canonicalVectorError(t, vector.WantError)
if !errors.Is(err, want) {
t.Fatalf("Verify() error = %v, want %v", err, want)
}
return
}
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
expectedPayload, err := base64.StdEncoding.DecodeString(vector.SignedPayloadBase64)
if err != nil {
t.Fatalf("decode static signed payload: %v", err)
}
if !bytes.Equal(verified.SignedPayload, expectedPayload) {
t.Fatalf(
"SignedPayload = %q, want static vector %q",
verified.SignedPayload,
expectedPayload,
)
}
signature, err := base64.StdEncoding.DecodeString(vector.Signature)
if err != nil {
t.Fatalf("decode static signature: %v", err)
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), expectedPayload, signature) {
t.Fatal("static signature does not verify the static signed payload")
}
if got := vectorDocumentSignature(t, vector.Document); got != vector.Signature {
t.Fatalf("document signature = %q, want static vector %q", got, vector.Signature)
}
})
}
}
func TestParserRejectsNonCanonicalSignatureVectorText(t *testing.T) {
corpus := readCanonicalVectorCorpus(t)
for _, vector := range corpus.Vectors {
if vector.WantError != "signature_invalid" {
continue
}
vector := vector
t.Run(vector.Name, func(t *testing.T) {
if err := validateSignature(vectorDocumentSignature(t, vector.Document)); err == nil {
t.Fatal("validateSignature() accepted a non-canonical signature text")
}
})
}
}
func TestParserRejectsNonCanonicalPackageSignatureVectors(t *testing.T) {
corpus := readCanonicalVectorCorpus(t)
for _, vector := range corpus.Vectors {
if vector.WantError != "signature_invalid" {
continue
}
vector := vector
t.Run(vector.Name, func(t *testing.T) {
manifest := validManifestForTest()
publishedPackage := manifest.Apps[0].Packages[ArchitectureAMD64]
publishedPackage.Signature = vectorDocumentSignature(t, vector.Document)
manifest.Apps[0].Packages[ArchitectureAMD64] = publishedPackage
_, err := parseSignedManifestForTest(t, manifest, ChannelModern)
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("Parse() error = %v, want %v", err, ErrInvalidManifest)
}
})
}
}
func readCanonicalVectorCorpus(t *testing.T) canonicalVectorCorpus {
t.Helper()
path := filepath.Join("..", "..", "testdata", "catalog", "canonical-vectors.json")
file, err := os.Open(path)
if err != nil {
t.Fatalf("open canonical vector corpus: %v", err)
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var corpus canonicalVectorCorpus
if err := decoder.Decode(&corpus); err != nil {
t.Fatalf("decode canonical vector corpus: %v", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
t.Fatalf("canonical vector corpus has trailing data: %v", err)
}
if corpus.SchemaVersion != 1 {
t.Fatalf("corpus schema_version = %d, want 1", corpus.SchemaVersion)
}
if len(corpus.Vectors) == 0 {
t.Fatal("corpus has no vectors")
}
return corpus
}
func canonicalVectorError(t *testing.T, value string) error {
t.Helper()
switch value {
case "invalid_document":
return ErrInvalidDocument
case "unsupported_number":
return ErrUnsupportedNumber
case "signature_invalid":
return ErrSignatureInvalid
default:
t.Fatalf("unsupported corpus want_error %q", value)
return nil
}
}
func vectorDocumentSignature(t *testing.T, document string) string {
t.Helper()
var root struct {
Signature string `json:"signature"`
}
if err := json.Unmarshal([]byte(document), &root); err != nil {
t.Fatalf("decode vector document signature: %v", err)
}
if root.Signature == "" {
t.Fatal("vector document has no signature")
}
return root.Signature
}
+84
View File
@@ -6,12 +6,14 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -209,6 +211,83 @@ func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) {
}
}
func TestIconCacheRejectsUnsafeDiskEntriesWithoutFallback(t *testing.T) {
document := testPNG(t, 16, 16)
request := iconRequest(document, 120)
t.Run("directory", func(t *testing.T) {
root := t.TempDir()
entryPath := testIconCacheEntryPath(root, request)
if err := os.Mkdir(entryPath, 0o700); err != nil {
t.Fatalf("Mkdir(cache entry) error = %v", err)
}
markerPath := filepath.Join(entryPath, "keep.txt")
if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil {
t.Fatalf("WriteFile(marker) error = %v", err)
}
fetchCalls := 0
cache := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
fetchCalls++
return iconResponse(document), nil
}))
_, err := cache.Load(context.Background(), request)
if !errors.Is(err, ErrIconCacheUnsafe) {
t.Fatalf("Load(directory) error = %v, want %v", err, ErrIconCacheUnsafe)
}
if fetchCalls != 0 {
t.Fatalf("unsafe directory triggered %d fetches", fetchCalls)
}
marker, readErr := os.ReadFile(markerPath)
if readErr != nil || string(marker) != "keep" {
t.Fatalf("unsafe directory marker = %q, %v", marker, readErr)
}
})
t.Run("symlink", func(t *testing.T) {
root := t.TempDir()
entryPath := testIconCacheEntryPath(root, request)
targetPath := filepath.Join(t.TempDir(), "external-target.icon")
target := []byte("external target must remain untouched")
if err := os.WriteFile(targetPath, target, 0o600); err != nil {
t.Fatalf("WriteFile(target) error = %v", err)
}
if err := os.Symlink(targetPath, entryPath); err != nil {
t.Skipf("symlink creation is unavailable: %v", err)
}
fetchCalls := 0
cache := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
fetchCalls++
return iconResponse(document), nil
}))
_, err := cache.Load(context.Background(), request)
if !errors.Is(err, ErrIconCacheUnsafe) {
t.Fatalf("Load(symlink) error = %v, want %v", err, ErrIconCacheUnsafe)
}
if fetchCalls != 0 {
t.Fatalf("unsafe symlink triggered %d fetches", fetchCalls)
}
gotTarget, readErr := os.ReadFile(targetPath)
if readErr != nil || !bytes.Equal(gotTarget, target) {
t.Fatalf("external target changed: %q, %v", gotTarget, readErr)
}
info, statErr := os.Lstat(entryPath)
if statErr != nil {
t.Fatalf("Lstat(unsafe symlink) error = %v", statErr)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("unsafe symlink was replaced: mode=%v", info.Mode())
}
})
}
func TestDecodeIcon(t *testing.T) {
document := testPNG(t, 8, 8)
decoded, err := DecodeIcon(document)
@@ -235,6 +314,11 @@ func iconResponse(document []byte) IconFetchResponse {
}
}
func testIconCacheEntryPath(root string, request IconRequest) string {
digest := strings.TrimPrefix(request.Reference, "sha256:")
return filepath.Join(root, fmt.Sprintf("%s-%d.icon", digest, request.DPI))
}
func testPNG(t *testing.T, width, height int) []byte {
t.Helper()
source := image.NewNRGBA(image.Rect(0, 0, width, height))
+59
View File
@@ -2,7 +2,9 @@ package catalog
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
@@ -109,6 +111,63 @@ func TestIconEventDeliveryClassifiesFailuresWithoutRawErrorPayload(t *testing.T)
}
}
func TestIconEventDeliveryPublishesUnsafeFailureFromRealCache(t *testing.T) {
document := testPNG(t, 8, 8)
request := iconRequest(document, 96)
identity := iconEventIdentity(t, document, "request-unsafe-cache")
root := t.TempDir()
entryPath := testIconCacheEntryPath(root, request)
if err := os.Mkdir(entryPath, 0o700); err != nil {
t.Fatalf("Mkdir(cache entry) error = %v", err)
}
fetchCalls := 0
cache := NewIconCache(root, IconFetchFunc(func(
context.Context,
IconRequest,
) (IconFetchResponse, error) {
fetchCalls++
return iconResponse(document), nil
}))
var events []application.Event
delivery := IconEventDelivery{
Loader: cache,
Publisher: IconEventPublisherFunc(func(
_ context.Context,
event application.Event,
) error {
events = append(events, event)
return nil
}),
}
err := delivery.LoadAndPublish(context.Background(), identity)
if !errors.Is(err, ErrIconCacheUnsafe) {
t.Fatalf("LoadAndPublish() error = %v, want %v", err, ErrIconCacheUnsafe)
}
if fetchCalls != 0 {
t.Fatalf("unsafe cache delivery triggered %d fetches", fetchCalls)
}
if len(events) != 1 {
t.Fatalf("published events = %d, want 1", len(events))
}
parsed, handled, parseErr := application.ParseIconEvent(events[0])
if parseErr != nil || !handled {
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, parseErr)
}
if parsed.Type != application.EventIconFailed || parsed.Identity != identity ||
parsed.ErrorCode != application.IconFailureUnsafe {
t.Fatalf("unsafe failure event = %+v", parsed)
}
encoded, marshalErr := json.Marshal(events[0])
if marshalErr != nil {
t.Fatalf("json.Marshal(event) error = %v", marshalErr)
}
if strings.Contains(string(encoded), root) ||
strings.Contains(string(encoded), "cache entry is not a regular file") {
t.Fatalf("unsafe failure event leaked cache details: %s", encoded)
}
}
func TestIconEventDeliveryReportsPublishFailureAndCacheWarning(t *testing.T) {
document := testPNG(t, 12, 12)
identity := iconEventIdentity(t, document, "request-publish")
+2 -6
View File
@@ -2,7 +2,6 @@ package catalog
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -231,12 +230,9 @@ func validatePackage(architecture Architecture, publishedPackage Package) error
}
func validateSignature(value string) error {
signature, err := base64.StdEncoding.Strict().DecodeString(value)
_, err := decodeCanonicalSignature(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 fmt.Errorf("must be canonical padded Base64 for 64 bytes: %v", err)
}
return nil
}
+20 -10
View File
@@ -66,17 +66,9 @@ func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) {
if err != nil {
return VerifiedDocument{}, err
}
signature, err := base64.StdEncoding.Strict().DecodeString(signatureText)
signature, err := decodeCanonicalSignature(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,
)
return VerifiedDocument{}, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
}
if !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
return VerifiedDocument{}, ErrSignatureInvalid
@@ -87,3 +79,21 @@ func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) {
SignedPayload: append([]byte(nil), signedPayload...),
}, nil
}
func decodeCanonicalSignature(value string) ([]byte, error) {
signature, err := base64.StdEncoding.Strict().DecodeString(value)
if err != nil {
return nil, fmt.Errorf("invalid standard Base64: %w", err)
}
if base64.StdEncoding.EncodeToString(signature) != value {
return nil, errors.New("signature must use canonical padded Base64")
}
if len(signature) != ed25519.SignatureSize {
return nil, fmt.Errorf(
"got %d signature bytes, want %d",
len(signature),
ed25519.SignatureSize,
)
}
return signature, nil
}
+99
View File
@@ -0,0 +1,99 @@
package installer
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
)
var ErrDurability = errors.New("install durability fence failed")
type durabilityFence interface {
syncFile(file *os.File) error
syncDirectory(path string) error
}
type filesystemDurability struct{}
func (filesystemDurability) syncFile(file *os.File) error {
return file.Sync()
}
func (filesystemDurability) syncDirectory(path string) error {
return syncDirectoryPath(path)
}
func defaultDurability() durabilityFence {
return filesystemDurability{}
}
func effectiveDurability(fence durabilityFence) durabilityFence {
if fence == nil {
return defaultDurability()
}
return fence
}
func syncFileWithFence(fence durabilityFence, file *os.File, description string) error {
if err := effectiveDurability(fence).syncFile(file); err != nil {
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
}
return nil
}
func syncDirectoryWithFence(fence durabilityFence, path, description string) error {
if err := effectiveDurability(fence).syncDirectory(path); err != nil {
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
}
return nil
}
func syncStagingTree(fence durabilityFence, root string) error {
directories := make([]string, 0)
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: staging tree contains a symbolic link", ErrUnsafeInstallLayout)
}
if entry.IsDir() {
directories = append(directories, path)
}
return nil
})
if err != nil {
return fmt.Errorf("%w: walk staging tree: %w", ErrDurability, err)
}
sort.Slice(directories, func(left, right int) bool {
return len(directories[left]) > len(directories[right])
})
for _, directory := range directories {
if err := syncDirectoryWithFence(fence, directory, "staging directory"); err != nil {
return err
}
}
parent := filepath.Dir(root)
if parent != root {
if err := syncDirectoryWithFence(fence, parent, "staging parent directory"); err != nil {
return err
}
}
return nil
}
func renameManagedDirectory(
layout appLayout,
source string,
target string,
fence durabilityFence,
description string,
) error {
if err := os.Rename(source, target); err != nil {
return err
}
return syncDirectoryWithFence(fence, layout.root, description)
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !windows
package installer
import (
"fmt"
"os"
)
func syncDirectoryPath(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open directory: %w", err)
}
if err := directory.Sync(); err != nil {
_ = directory.Close()
return fmt.Errorf("sync directory: %w", err)
}
if err := directory.Close(); err != nil {
return fmt.Errorf("close directory: %w", err)
}
return nil
}
+334
View File
@@ -0,0 +1,334 @@
package installer
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
type durabilityEvent struct {
kind string
path string
}
type recordingDurabilityFence struct {
events []durabilityEvent
fail func(durabilityEvent) error
failNextDirectory bool
}
func (fence *recordingDurabilityFence) syncFile(file *os.File) error {
return fence.record(durabilityEvent{kind: "file", path: file.Name()})
}
func (fence *recordingDurabilityFence) syncDirectory(path string) error {
event := durabilityEvent{kind: "directory", path: path}
if fence.failNextDirectory {
fence.failNextDirectory = false
fence.events = append(fence.events, event)
return errors.New("injected directory fence failure")
}
return fence.record(event)
}
func (fence *recordingDurabilityFence) record(event durabilityEvent) error {
fence.events = append(fence.events, event)
if fence.fail != nil {
return fence.fail(event)
}
return nil
}
func TestExtractorDurabilityFencesPayloadThenStagingTree(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{"entrypoint":"bin/nested/App.exe"}`)},
{name: "payload/bin/", mode: os.ModeDir | 0o755},
{name: "payload/bin/nested/", mode: os.ModeDir | 0o755},
{name: "payload/bin/nested/App.exe", body: []byte("executable"), mode: 0o755},
{name: "payload/readme.txt", body: []byte("readme")},
})
destination := filepath.Join(t.TempDir(), "staging")
fence := &recordingDurabilityFence{}
extractor := mustExtractor(t, testLimits())
extractor.durability = fence
if _, err := extractor.ExtractFile(
archivePath,
destination,
"bin/nested/App.exe",
archiveSize(t, archivePath),
); err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
want := []durabilityEvent{
{kind: "file", path: filepath.Join(destination, "bin", "nested", "App.exe")},
{kind: "file", path: filepath.Join(destination, "readme.txt")},
{kind: "directory", path: filepath.Join(destination, "bin", "nested")},
{kind: "directory", path: filepath.Join(destination, "bin")},
{kind: "directory", path: destination},
{kind: "directory", path: filepath.Dir(destination)},
}
assertDurabilityEvents(t, fence.events, want)
}
func TestExtractorDurabilityFailuresRemoveStaging(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{"entrypoint":"App.exe"}`)},
{name: "payload/App.exe", body: []byte("executable"), mode: 0o755},
})
for _, test := range []struct {
name string
fail func(durabilityEvent) error
}{
{
name: "payload sync",
fail: func(event durabilityEvent) error {
if event.kind == "file" {
return errors.New("injected payload sync failure")
}
return nil
},
},
{
name: "staging tree sync",
fail: func(event durabilityEvent) error {
if event.kind == "directory" {
return errors.New("injected staging tree sync failure")
}
return nil
},
},
} {
t.Run(test.name, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
fence := &recordingDurabilityFence{fail: test.fail}
extractor := mustExtractor(t, testLimits())
extractor.durability = fence
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrDurability) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDurability)
}
assertMissing(t, destination)
})
}
}
func TestWriteTransactionFailsWhenJournalRootFenceFails(t *testing.T) {
root := t.TempDir()
layout, err := inspectAppLayout(root)
if err != nil {
t.Fatalf("inspectAppLayout() error = %v", err)
}
fence := &recordingDurabilityFence{fail: func(event durabilityEvent) error {
if event.kind == "directory" {
return errors.New("injected journal root fence failure")
}
return nil
}}
err = writeTransactionWithFence(layout, newTransaction(phasePrepared, true), fence)
if !errors.Is(err, ErrDurability) {
t.Fatalf("writeTransactionWithFence() error = %v, want %v", err, ErrDurability)
}
if len(fence.events) != 2 || fence.events[0].kind != "file" ||
fence.events[1] != (durabilityEvent{kind: "directory", path: root}) {
t.Fatalf("journal fences = %#v, want temporary file then app-root directory", fence.events)
}
if _, exists, err := loadTransaction(layout); err != nil || !exists {
t.Fatalf("loadTransaction() exists=%t error=%v, want prepared journal retained for recovery", exists, err)
}
}
func TestSwitcherFencesEachPhaseBeforeAfterStep(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
fence := &recordingDurabilityFence{}
switcher := NewSwitcher(func(currentPath string) error {
assertVersion(t, currentPath, "new")
return nil
})
switcher.durability = fence
switcher.afterStep = func(step switchStep) error {
if len(fence.events) == 0 {
return fmt.Errorf("%s ran without a durability fence", step)
}
last := fence.events[len(fence.events)-1]
if last != (durabilityEvent{kind: "directory", path: root}) {
return fmt.Errorf("%s ran after %#v, want app-root directory fence", step, last)
}
return nil
}
if err := switcher.Switch(root); err != nil {
t.Fatalf("Switch() error = %v", err)
}
if !hasJournalFileFence(fence.events) {
t.Fatalf("fences = %#v, want temporary journal file sync", fence.events)
}
if countDirectoryFences(fence.events, root) < 10 {
t.Fatalf("app-root directory fences = %d, want at least 10", countDirectoryFences(fence.events, root))
}
}
func TestSwitcherRenameFenceFailureLeavesRecoverablePreparedJournal(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
fence := &recordingDurabilityFence{}
switcher := NewSwitcher(func(string) error { return nil })
switcher.durability = fence
switcher.afterStep = func(step switchStep) error {
if step == stepPrepared {
fence.failNextDirectory = true
}
return nil
}
err := switcher.Switch(root)
if !errors.Is(err, ErrDurability) {
t.Fatalf("Switch() error = %v, want %v", err, ErrDurability)
}
layout, layoutErr := inspectAppLayout(root)
if layoutErr != nil {
t.Fatalf("inspectAppLayout() error = %v", layoutErr)
}
record, exists, loadErr := loadTransaction(layout)
if loadErr != nil || !exists || record.Phase != phasePrepared {
t.Fatalf("transaction = %#v exists=%t error=%v, want prepared journal", record, exists, loadErr)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryRolledBack {
t.Fatalf("Recovery 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"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func TestRollbackAndRecoveryFenceFailuresRemainRecoverable(t *testing.T) {
t.Run("rollback", func(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
fence := &recordingDurabilityFence{}
switcher := NewSwitcher(func(string) error { return errors.New("health failed") })
switcher.durability = fence
switcher.afterStep = func(step switchStep) error {
if step == stepRollbackRequired {
fence.failNextDirectory = true
}
return nil
}
err := switcher.Switch(root)
var rollbackErr *RollbackError
if !errors.As(err, &rollbackErr) || !errors.Is(rollbackErr.Rollback, ErrDurability) {
t.Fatalf("Switch() error = %v, want rollback durability failure", err)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryRolledBack {
t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryRolledBack)
}
assertVersion(t, filepath.Join(root, "current"), "old")
})
t.Run("recovery", func(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)
}
fence := &recordingDurabilityFence{failNextDirectory: true}
if _, err := recoverWithFence(root, fence); !errors.Is(err, ErrDurability) {
t.Fatalf("recoverWithFence() error = %v, want %v", err, ErrDurability)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryRolledBack {
t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryRolledBack)
}
assertVersion(t, filepath.Join(root, "current"), "old")
})
}
func TestCommittedCleanupFenceFailureRemainsRecoverable(t *testing.T) {
root := makeInstallRoot(t, "old", "new")
fence := &recordingDurabilityFence{}
switcher := NewSwitcher(func(string) error { return nil })
switcher.durability = fence
switcher.afterStep = func(step switchStep) error {
if step == stepCommitted {
fence.failNextDirectory = true
}
return nil
}
err := switcher.Switch(root)
if !errors.Is(err, ErrRecoveryRequired) || !errors.Is(err, ErrDurability) {
t.Fatalf("Switch() error = %v, want recovery-required durability failure", err)
}
result, err := Recover(root)
if err != nil {
t.Fatalf("Recover() error = %v", err)
}
if result.Action != RecoveryCommitted {
t.Fatalf("Recovery action = %q, want %q", result.Action, RecoveryCommitted)
}
assertVersion(t, filepath.Join(root, "current"), "new")
assertMissing(t, filepath.Join(root, "backup"))
assertMissing(t, filepath.Join(root, transactionFileName))
}
func assertDurabilityEvents(t *testing.T, got, want []durabilityEvent) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("durability events = %#v, want %#v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("durability event %d = %#v, want %#v", index, got[index], want[index])
}
}
}
func hasJournalFileFence(events []durabilityEvent) bool {
for _, event := range events {
if event.kind == "file" && strings.HasPrefix(filepath.Base(event.path), ".install-transaction-") {
return true
}
}
return false
}
func countDirectoryFences(events []durabilityEvent, path string) int {
count := 0
for _, event := range events {
if event == (durabilityEvent{kind: "directory", path: path}) {
count++
}
}
return count
}
+35
View File
@@ -0,0 +1,35 @@
//go:build windows
package installer
import (
"fmt"
"syscall"
)
func syncDirectoryPath(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path: %w", err)
}
handle, err := syscall.CreateFile(
pathPointer,
syscall.GENERIC_READ|syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
nil,
syscall.OPEN_EXISTING,
syscall.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return fmt.Errorf("open directory handle: %w", err)
}
if err := syscall.FlushFileBuffers(handle); err != nil {
_ = syscall.CloseHandle(handle)
return fmt.Errorf("flush directory handle: %w", err)
}
if err := syscall.CloseHandle(handle); err != nil {
return fmt.Errorf("close directory handle: %w", err)
}
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package installer
import "testing"
func TestSyncDirectoryPath(t *testing.T) {
if err := syncDirectoryPath(t.TempDir()); err != nil {
t.Fatalf("syncDirectoryPath() error = %v", err)
}
}
+95 -26
View File
@@ -2,6 +2,8 @@ package installer
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -14,31 +16,44 @@ import (
)
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")
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")
ErrArchiveSizeMismatch = errors.New("ZIP archive size does not match expected package size")
ErrArchiveTooLarge = errors.New("ZIP archive size limit exceeded")
ErrCentralDirectoryTooLarge = errors.New("ZIP central directory size limit exceeded")
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
limits Limits
durability durabilityFence
}
type ExtractResult struct {
Files int
Bytes int64
EntrypointPath string
PayloadFiles []ExtractedFile
}
// ExtractedFile is one payload file written to staging after ZIP CRC and
// length validation. Its digest is calculated from the bytes written there.
type ExtractedFile struct {
Path string
Size int64
SHA256 string
}
type plannedEntry struct {
@@ -49,25 +64,51 @@ type plannedEntry struct {
directory bool
}
func verifiedPackageFromPlan(plan []plannedEntry, entrypoint string) (VerifiedPackage, error) {
verified := VerifiedPackage{Entrypoint: entrypoint}
for _, entry := range plan {
if entry.directory {
continue
}
if entry.file == nil || entry.file.UncompressedSize64 > uint64(math.MaxInt64) {
return VerifiedPackage{}, ErrExpandedTooLarge
}
size := int64(entry.file.UncompressedSize64)
if verified.PayloadBytes > math.MaxInt64-size {
return VerifiedPackage{}, ErrExpandedTooLarge
}
verified.PayloadBytes += size
verified.PayloadFiles++
}
return verified, nil
}
func NewExtractor(limits Limits) (Extractor, error) {
if err := limits.validate(); err != nil {
return Extractor{}, err
}
return Extractor{limits: limits}, nil
return Extractor{limits: limits, durability: defaultDurability()}, nil
}
// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks.
// ExtractFile requires expectedPackageSize from the verified Catalog package.
// The completed download file must have precisely that size before any ZIP data is parsed.
func (extractor Extractor) ExtractFile(
zipPath string,
destination string,
entrypoint string,
expectedPackageSize int64,
) (ExtractResult, error) {
archive, err := zip.OpenReader(zipPath)
file, size, err := extractor.openAndScanArchive(zipPath, expectedPackageSize)
if err != nil {
return ExtractResult{}, err
}
defer file.Close()
archive, err := zip.NewReader(file, size)
if err != nil {
return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err)
}
defer archive.Close()
return extractor.extract(&archive.Reader, destination, entrypoint)
return extractor.extract(archive, destination, entrypoint)
}
func (extractor Extractor) extract(
@@ -86,9 +127,18 @@ func (extractor Extractor) extract(
if err != nil {
return ExtractResult{}, err
}
return extractor.extractPlan(destination, normalizedEntrypoint, plan)
}
func (extractor Extractor) extractPlan(
destination string,
entrypoint string,
plan []plannedEntry,
) (result ExtractResult, err error) {
fence := effectiveDurability(extractor.durability)
destinationRoot, entrypointPath, err := planOutputPaths(
destination,
normalizedEntrypoint,
entrypoint,
plan,
)
if err != nil {
@@ -149,22 +199,26 @@ func (extractor Extractor) extract(
if readLimit < math.MaxInt64 {
readLimit++
}
copied, copyErr := io.Copy(output, io.LimitReader(source, readLimit))
closeOutputErr := output.Close()
digest := sha256.New()
copied, copyErr := io.Copy(
io.MultiWriter(output, digest),
io.LimitReader(source, readLimit),
)
closeSourceErr := source.Close()
if copyErr != nil {
_ = output.Close()
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 {
_ = output.Close()
return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr)
}
if copied > remaining {
_ = output.Close()
return ExtractResult{}, ErrExpandedTooLarge
}
if uint64(copied) != entry.file.UncompressedSize64 {
_ = output.Close()
return ExtractResult{}, fmt.Errorf(
"%w: %s expanded to %d bytes, header declares %d",
ErrArchiveCorrupt,
@@ -173,8 +227,23 @@ func (extractor Extractor) extract(
entry.file.UncompressedSize64,
)
}
if err := syncFileWithFence(fence, output, "staging payload"); err != nil {
_ = output.Close()
return ExtractResult{}, err
}
if err := output.Close(); err != nil {
return ExtractResult{}, fmt.Errorf("close staging file: %w", err)
}
written += copied
result.Files++
result.PayloadFiles = append(result.PayloadFiles, ExtractedFile{
Path: entry.outputPath,
Size: copied,
SHA256: hex.EncodeToString(digest.Sum(nil)),
})
}
if err := syncStagingTree(fence, destinationRoot); err != nil {
return ExtractResult{}, err
}
result.Bytes = written
+73 -23
View File
@@ -21,7 +21,12 @@ func TestExtractorExtractsPayloadOnly(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe")
result, err := extractor.ExtractFile(
archivePath,
destination,
"bin/App.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
@@ -216,9 +221,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 2,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
MaxEntries: 2,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
},
wantErr: ErrTooManyEntries,
},
@@ -230,9 +237,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
},
wantErr: ErrExpandedTooLarge,
},
@@ -248,9 +257,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
},
wantErr: ErrCompressionRatio,
},
@@ -263,7 +274,12 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
_, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
@@ -303,7 +319,12 @@ func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
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)
_, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
@@ -323,7 +344,12 @@ func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe")
result, err := extractor.ExtractFile(
archivePath,
destination,
"工具/解析器.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
@@ -346,7 +372,12 @@ func TestExtractorRejectsExistingDestination(t *testing.T) {
}
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrDestinationExists) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
}
@@ -361,7 +392,12 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrArchiveCorrupt) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
}
@@ -380,12 +416,23 @@ type testZIPEntry struct {
func testLimits() Limits {
return Limits{
MaxEntries: 20,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
MaxEntries: 20,
MaxArchiveBytes: 64 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
}
}
func archiveSize(t *testing.T, archivePath string) int64 {
t.Helper()
info, err := os.Stat(archivePath)
if err != nil {
t.Fatalf("stat archive: %v", err)
}
return info.Size()
}
func mustExtractor(t *testing.T, limits Limits) Extractor {
t.Helper()
extractor, err := NewExtractor(limits)
@@ -483,11 +530,14 @@ func TestDefaultLimitsAreValid(t *testing.T) {
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)},
{MaxEntries: 0, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 0, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 2, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
}
for index, limits := range tests {
+6 -1
View File
@@ -19,7 +19,12 @@ func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T)
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrPathEscape) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape)
}
+9 -1
View File
@@ -89,6 +89,14 @@ func inspectManagedDirectory(path string) (bool, error) {
}
func removeManagedDirectory(layout appLayout, target string) error {
return removeManagedDirectoryWithFence(layout, target, defaultDurability())
}
func removeManagedDirectoryWithFence(
layout appLayout,
target string,
fence durabilityFence,
) error {
if filepath.Dir(target) != layout.root {
return fmt.Errorf("%w: refuse removal outside app root", ErrUnsafeInstallLayout)
}
@@ -106,5 +114,5 @@ func removeManagedDirectory(layout appLayout, target string) error {
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("remove managed directory %s: %w", base, err)
}
return nil
return syncDirectoryWithFence(fence, layout.root, "app root after directory removal")
}
+24 -9
View File
@@ -9,23 +9,29 @@ import (
var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
const (
DefaultMaxEntries = 10_000
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCompressionRatio = 200.0
DefaultMaxEntries = 10_000
DefaultMaxArchiveBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCentralDirectoryBytes = int64(64 * 1024 * 1024)
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
MaxEntries int
MaxArchiveBytes int64
MaxCentralDirectoryBytes int64
MaxUncompressedBytes int64
MaxCompressionRatio float64
}
func DefaultLimits() Limits {
return Limits{
MaxEntries: DefaultMaxEntries,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
MaxEntries: DefaultMaxEntries,
MaxArchiveBytes: DefaultMaxArchiveBytes,
MaxCentralDirectoryBytes: DefaultMaxCentralDirectoryBytes,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
}
}
@@ -33,6 +39,15 @@ func (limits Limits) validate() error {
if limits.MaxEntries <= 0 {
return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits)
}
if limits.MaxArchiveBytes <= 0 {
return fmt.Errorf("%w: MaxArchiveBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCentralDirectoryBytes <= 0 {
return fmt.Errorf("%w: MaxCentralDirectoryBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCentralDirectoryBytes > limits.MaxArchiveBytes {
return fmt.Errorf("%w: MaxCentralDirectoryBytes exceeds MaxArchiveBytes", ErrInvalidLimits)
}
if limits.MaxUncompressedBytes <= 0 {
return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits)
}
+36 -18
View File
@@ -3,7 +3,6 @@ package installer
import (
"errors"
"fmt"
"os"
)
var ErrRecoveryInconsistent = errors.New("install recovery state is inconsistent")
@@ -24,6 +23,11 @@ type RecoveryResult struct {
// Recover resolves an interrupted transaction from journal and directory state.
func Recover(root string) (RecoveryResult, error) {
return recoverWithFence(root, defaultDurability())
}
func recoverWithFence(root string, fence durabilityFence) (RecoveryResult, error) {
fence = effectiveDurability(fence)
layout, err := inspectAppLayout(root)
if err != nil {
return RecoveryResult{}, err
@@ -57,18 +61,18 @@ func Recover(root string) (RecoveryResult, error) {
state.staging,
)
}
if err := removeManagedDirectory(layout, layout.backup); err != nil {
if err := removeManagedDirectoryWithFence(layout, layout.backup, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryCommitted
return result, nil
case phaseRollbackRequired:
return recoverRollbackRequired(layout, record, state, result)
return recoverRollbackRequired(layout, record, state, result, fence)
case phasePrepared, phaseCurrentBackedUp, phaseStagingActivated:
return recoverUncommitted(layout, record, state, result)
return recoverUncommitted(layout, record, state, result, fence)
default:
return RecoveryResult{}, fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase)
}
@@ -79,6 +83,7 @@ func recoverUncommitted(
record transactionRecord,
state directoryState,
result RecoveryResult,
fence durabilityFence,
) (RecoveryResult, error) {
if record.HadCurrent {
if state.backup {
@@ -89,27 +94,39 @@ func recoverUncommitted(
)
}
if state.current {
if err := os.Rename(layout.current, layout.staging); err != nil {
if err := renameManagedDirectory(
layout,
layout.current,
layout.staging,
fence,
"app root after recovery staging rename",
); err != nil {
return RecoveryResult{}, fmt.Errorf("move unverified current aside: %w", err)
}
}
if err := os.Rename(layout.backup, layout.current); err != nil {
if err := renameManagedDirectory(
layout,
layout.backup,
layout.current,
fence,
"app root after recovery current restore",
); err != nil {
return RecoveryResult{}, fmt.Errorf("restore backup during recovery: %w", err)
}
if err := removeManagedDirectory(layout, layout.staging); err != nil {
if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); 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 {
if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryAborted
@@ -127,13 +144,13 @@ func recoverUncommitted(
ErrRecoveryInconsistent,
)
}
if err := removeManagedDirectory(layout, layout.current); err != nil {
if err := removeManagedDirectoryWithFence(layout, layout.current, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeManagedDirectory(layout, layout.staging); err != nil {
if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryAborted
@@ -145,6 +162,7 @@ func recoverRollbackRequired(
record transactionRecord,
state directoryState,
result RecoveryResult,
fence durabilityFence,
) (RecoveryResult, error) {
if record.HadCurrent && !state.backup {
if !state.current {
@@ -153,19 +171,19 @@ func recoverRollbackRequired(
ErrRecoveryInconsistent,
)
}
if err := removeManagedDirectory(layout, layout.staging); err != nil {
if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryRolledBack
return result, nil
}
if err := rollbackActivated(layout, record.HadCurrent); err != nil {
if err := rollbackActivated(layout, record.HadCurrent, fence); err != nil {
return RecoveryResult{}, err
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); err != nil {
return RecoveryResult{}, err
}
result.Action = RecoveryRolledBack
+64 -26
View File
@@ -36,24 +36,26 @@ 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
func (err *RollbackError) Unwrap() []error {
return []error{ErrRollbackFailed, err.Health, err.Rollback}
}
// Switcher activates a verified staging directory and runs an injected check.
type Switcher struct {
health HealthCheck
afterStep func(switchStep) error
health HealthCheck
afterStep func(switchStep) error
durability durabilityFence
}
func NewSwitcher(health HealthCheck) *Switcher {
return &Switcher{health: health}
return &Switcher{health: health, durability: defaultDurability()}
}
func (switcher *Switcher) Switch(root string) error {
if switcher.health == nil {
return ErrHealthCheckRequired
}
fence := effectiveDurability(switcher.durability)
layout, err := inspectAppLayout(root)
if err != nil {
return err
@@ -75,7 +77,7 @@ func (switcher *Switcher) Switch(root string) error {
}
record := newTransaction(phasePrepared, state.current)
if err := writeTransaction(layout, record); err != nil {
if err := writeTransactionWithFence(layout, record, fence); err != nil {
return err
}
if err := switcher.runStep(stepPrepared); err != nil {
@@ -83,7 +85,13 @@ func (switcher *Switcher) Switch(root string) error {
}
if state.current {
if err := os.Rename(layout.current, layout.backup); err != nil {
if err := renameManagedDirectory(
layout,
layout.current,
layout.backup,
fence,
"app root after current backup rename",
); err != nil {
return fmt.Errorf("backup current directory: %w", err)
}
if err := switcher.runStep(stepCurrentRenamed); err != nil {
@@ -91,21 +99,27 @@ func (switcher *Switcher) Switch(root string) error {
}
}
record.Phase = phaseCurrentBackedUp
if err := writeTransaction(layout, record); err != nil {
if err := writeTransactionWithFence(layout, record, fence); err != nil {
return err
}
if err := switcher.runStep(stepCurrentBackedUp); err != nil {
return err
}
if err := os.Rename(layout.staging, layout.current); err != nil {
if err := renameManagedDirectory(
layout,
layout.staging,
layout.current,
fence,
"app root after staging activation rename",
); 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 {
if err := writeTransactionWithFence(layout, record, fence); err != nil {
return err
}
if err := switcher.runStep(stepStagingActivated); err != nil {
@@ -115,35 +129,35 @@ func (switcher *Switcher) Switch(root string) error {
healthErr := switcher.health(layout.current)
if healthErr != nil {
record.Phase = phaseRollbackRequired
if err := writeTransaction(layout, record); err != nil {
if err := writeTransactionWithFence(layout, record, fence); 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 {
if err := rollbackActivated(layout, record.HadCurrent, fence); err != nil {
return &RollbackError{Health: healthErr, Rollback: err}
}
if err := removeTransaction(layout); err != nil {
if err := removeTransactionWithFence(layout, fence); err != nil {
return &RollbackError{Health: healthErr, Rollback: err}
}
return fmt.Errorf("%w: %v", ErrHealthCheckFailed, healthErr)
return fmt.Errorf("%w: %w", ErrHealthCheckFailed, healthErr)
}
record.Phase = phaseCommitted
if err := writeTransaction(layout, record); err != nil {
if err := writeTransactionWithFence(layout, record, fence); 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 := removeManagedDirectoryWithFence(layout, layout.backup, fence); err != nil {
return fmt.Errorf("%w: cleanup committed backup: %w", ErrRecoveryRequired, err)
}
}
if err := removeTransaction(layout); err != nil {
return fmt.Errorf("%w: %v", ErrRecoveryRequired, err)
if err := removeTransactionWithFence(layout, fence); err != nil {
return fmt.Errorf("%w: %w", ErrRecoveryRequired, err)
}
return nil
}
@@ -155,7 +169,7 @@ func (switcher *Switcher) runStep(step switchStep) error {
return switcher.afterStep(step)
}
func rollbackActivated(layout appLayout, hadCurrent bool) error {
func rollbackActivated(layout appLayout, hadCurrent bool, fence durabilityFence) error {
state, err := inspectDirectories(layout)
if err != nil {
return err
@@ -168,17 +182,35 @@ func rollbackActivated(layout appLayout, hadCurrent bool) error {
if state.staging {
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
}
if err := os.Rename(layout.current, layout.staging); err != nil {
if err := renameManagedDirectory(
layout,
layout.current,
layout.staging,
fence,
"app root after rollback staging rename",
); err != nil {
return fmt.Errorf("move failed current aside: %w", err)
}
}
if err := os.Rename(layout.backup, layout.current); err != nil {
if err := renameManagedDirectory(
layout,
layout.backup,
layout.current,
fence,
"app root after rollback current restore",
); err != nil {
if _, statErr := os.Stat(layout.staging); statErr == nil {
_ = os.Rename(layout.staging, layout.current)
_ = renameManagedDirectory(
layout,
layout.staging,
layout.current,
fence,
"app root after rollback restore",
)
}
return fmt.Errorf("restore previous current: %w", err)
}
return removeManagedDirectory(layout, layout.staging)
return removeManagedDirectoryWithFence(layout, layout.staging, fence)
}
if state.backup {
@@ -188,9 +220,15 @@ func rollbackActivated(layout appLayout, hadCurrent bool) error {
if state.staging {
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
}
if err := os.Rename(layout.current, layout.staging); err != nil {
if err := renameManagedDirectory(
layout,
layout.current,
layout.staging,
fence,
"app root after initial rollback rename",
); err != nil {
return fmt.Errorf("move failed initial install aside: %w", err)
}
}
return removeManagedDirectory(layout, layout.staging)
return removeManagedDirectoryWithFence(layout, layout.staging, fence)
}
+40 -3
View File
@@ -66,6 +66,14 @@ func (record transactionRecord) validate() error {
}
func writeTransaction(layout appLayout, record transactionRecord) error {
return writeTransactionWithFence(layout, record, defaultDurability())
}
func writeTransactionWithFence(
layout appLayout,
record transactionRecord,
fence durabilityFence,
) error {
if err := record.validate(); err != nil {
return err
}
@@ -79,6 +87,7 @@ func writeTransaction(layout appLayout, record transactionRecord) error {
layout.transaction,
layout.transactionBackup,
data,
fence,
); err != nil {
return fmt.Errorf("write install transaction: %w", err)
}
@@ -135,11 +144,23 @@ func ensureJSONEOF(decoder *json.Decoder) error {
}
func removeTransaction(layout appLayout) error {
return removeTransactionWithFence(layout, defaultDurability())
}
func removeTransactionWithFence(layout appLayout, fence durabilityFence) error {
removed := false
if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove install transaction: %w", err)
} else if err == nil {
removed = true
}
if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove install transaction backup: %w", err)
} else if err == nil {
removed = true
}
if removed {
return syncDirectoryWithFence(fence, layout.root, "app root after transaction removal")
}
return nil
}
@@ -149,6 +170,7 @@ func replaceFileWithBackup(
target string,
backup string,
data []byte,
fence durabilityFence,
) error {
temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp")
if err != nil {
@@ -165,7 +187,7 @@ func replaceFileWithBackup(
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
if err := syncFileWithFence(fence, temporary, "temporary install transaction"); err != nil {
temporary.Close()
return err
}
@@ -178,12 +200,19 @@ func replaceFileWithBackup(
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) {
if err := os.Remove(backup); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := syncDirectoryWithFence(fence, directory, "app root after stale transaction backup removal"); err != nil {
return err
}
if err := os.Rename(target, backup); err != nil {
return err
}
if err := syncDirectoryWithFence(fence, directory, "app root after transaction backup rename"); err != nil {
return err
}
movedTarget = true
} else if !os.IsNotExist(err) {
return err
@@ -191,14 +220,22 @@ func replaceFileWithBackup(
if err := os.Rename(temporaryPath, target); err != nil {
if movedTarget {
_ = os.Rename(backup, target)
if restoreErr := os.Rename(backup, target); restoreErr == nil {
_ = syncDirectoryWithFence(fence, directory, "app root after transaction restore")
}
}
return err
}
if err := syncDirectoryWithFence(fence, directory, "app root after transaction replace"); err != nil {
return err
}
if movedTarget {
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return err
}
if err := syncDirectoryWithFence(fence, directory, "app root after transaction backup removal"); err != nil {
return err
}
}
return nil
}
+427
View File
@@ -0,0 +1,427 @@
package installer
import (
"archive/zip"
"bytes"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"softbox.local/core/domain"
"softbox.local/core/internal/safepath"
)
const MaxAppManifestBytes = 1 << 20
var (
ErrPackageExpectationInvalid = errors.New("invalid verified package expectation")
ErrPackageHashMismatch = errors.New("package SHA-256 does not match Catalog")
ErrAppManifestTooLarge = errors.New("package app.json exceeds size limit")
ErrAppManifestInvalid = errors.New("package app.json is invalid")
ErrPackageIdentityMismatch = errors.New("package app.json does not match Catalog")
)
var packageIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// PackageStage identifies the point at which a verified package install
// stopped. It is intentionally independent of UI wording.
type PackageStage string
const (
PackageStageVerify PackageStage = "verify"
PackageStageManifest PackageStage = "manifest"
PackageStagePreflight PackageStage = "preflight"
PackageStageExtract PackageStage = "extract"
)
// PackageError preserves the underlying safe failure while making the package
// boundary observable to its application caller.
type PackageError struct {
Stage PackageStage
Err error
}
func (err *PackageError) Error() string {
return fmt.Sprintf("package %s: %v", err.Stage, err.Err)
}
func (err *PackageError) Unwrap() error {
return err.Err
}
// AppExpectation is the portion of app.json that must equal the selected
// signed Catalog entry. The remaining v1 fields are still validated locally.
type AppExpectation struct {
ID string
Version string
Channel string
MinOS string
Architecture string
Entrypoint string
RequiresAdmin bool
}
// PackageExpectation is selected from a verified Catalog package. The outer
// Catalog signature is the trust root for Size and SHA256.
type PackageExpectation struct {
Size int64
SHA256 string
App AppExpectation
}
// VerifiedPackage describes the payload plan after the download, ZIP layout,
// and app manifest have all been verified. It intentionally contains no ZIP
// handles or destination paths, so callers cannot bypass safe extraction.
type VerifiedPackage struct {
PayloadBytes int64
PayloadFiles int
Entrypoint string
}
// PreExtractCheck runs after package verification but before the extraction
// destination is created. It lets application code enforce environment
// preconditions without introducing application or platform dependencies here.
type PreExtractCheck func(VerifiedPackage) error
type packageAppManifest struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Name string `json:"name"`
Vendor string `json:"vendor"`
Version string `json:"version"`
Channel string `json:"channel"`
MinOS string `json:"min_os"`
Architecture string `json:"architecture"`
Entrypoint string `json:"entrypoint"`
WorkingDir string `json:"working_directory"`
ProductID string `json:"product_id"`
SupportsTrial bool `json:"supports_trial"`
RequiresAdmin bool `json:"requires_admin"`
DataPolicy string `json:"data_policy"`
UpdatePolicy string `json:"update_policy"`
}
var appManifestFields = map[string]struct{}{
"schema_version": {},
"id": {},
"name": {},
"vendor": {},
"version": {},
"channel": {},
"min_os": {},
"architecture": {},
"entrypoint": {},
"working_directory": {},
"product_id": {},
"supports_trial": {},
"requires_admin": {},
"data_policy": {},
"update_policy": {},
}
// ExtractVerifiedFile preserves one file handle from Catalog size/SHA-256
// verification through ZIP scanning, manifest comparison and safe extraction.
func (extractor Extractor) ExtractVerifiedFile(
zipPath string,
destination string,
expectation PackageExpectation,
) (ExtractResult, error) {
return extractor.ExtractVerifiedFileWithCheck(zipPath, destination, expectation, nil)
}
// ExtractVerifiedFileWithCheck preserves one file handle from Catalog
// size/SHA-256 verification through ZIP scanning, manifest comparison, an
// optional environment precheck, and safe extraction.
func (extractor Extractor) ExtractVerifiedFileWithCheck(
zipPath string,
destination string,
expectation PackageExpectation,
beforeExtract PreExtractCheck,
) (ExtractResult, error) {
expectedHash, err := expectation.validate()
if err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
file, size, err := extractor.openArchiveFile(zipPath, expectation.Size)
if err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
defer file.Close()
if err := verifyPackageSHA256(file, size, expectedHash); err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
if err := extractor.scanOpenedArchive(file, size); err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
archive, err := zip.NewReader(file, size)
if err != nil {
return ExtractResult{}, packageError(
PackageStageVerify,
fmt.Errorf("%w: %v", ErrInvalidArchive, err),
)
}
normalizedEntrypoint, err := normalizeEntrypoint(expectation.App.Entrypoint)
if err != nil {
return ExtractResult{}, packageError(PackageStageManifest, err)
}
plan, err := extractor.preflight(archive, normalizedEntrypoint)
if err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
manifest, err := readPackageAppManifest(archive)
if err != nil {
return ExtractResult{}, packageError(PackageStageManifest, err)
}
if err := manifest.matches(expectation.App); err != nil {
return ExtractResult{}, packageError(PackageStageManifest, err)
}
if beforeExtract != nil {
verified, err := verifiedPackageFromPlan(plan, normalizedEntrypoint)
if err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
if err := beforeExtract(verified); err != nil {
return ExtractResult{}, packageError(PackageStagePreflight, err)
}
}
result, err := extractor.extractPlan(destination, normalizedEntrypoint, plan)
if err != nil {
return ExtractResult{}, packageError(PackageStageExtract, err)
}
return result, nil
}
func packageError(stage PackageStage, err error) error {
return &PackageError{Stage: stage, Err: err}
}
func (expectation PackageExpectation) validate() ([]byte, error) {
if expectation.Size <= 0 {
return nil, fmt.Errorf("%w: package size must be positive", ErrPackageExpectationInvalid)
}
expectedHash, err := hex.DecodeString(expectation.SHA256)
if err != nil || len(expectedHash) != sha256.Size {
return nil, fmt.Errorf("%w: SHA-256 must be 32 bytes", ErrPackageExpectationInvalid)
}
if err := validateExpectationApp(expectation.App); err != nil {
return nil, err
}
return expectedHash, nil
}
func validateExpectationApp(expectation AppExpectation) error {
if !packageIDPattern.MatchString(expectation.ID) {
return fmt.Errorf("%w: invalid app id", ErrPackageExpectationInvalid)
}
if _, err := domain.ParseSemVer(expectation.Version); err != nil {
return fmt.Errorf("%w: version: %v", ErrPackageExpectationInvalid, err)
}
if expectation.Channel != "stable" {
return fmt.Errorf("%w: channel=%q", ErrPackageExpectationInvalid, expectation.Channel)
}
if !isSupportedMinOS(expectation.MinOS) {
return fmt.Errorf("%w: min_os=%q", ErrPackageExpectationInvalid, expectation.MinOS)
}
if expectation.Architecture != "386" && expectation.Architecture != "amd64" {
return fmt.Errorf("%w: architecture=%q", ErrPackageExpectationInvalid, expectation.Architecture)
}
if _, err := normalizeEntrypoint(expectation.Entrypoint); err != nil {
return fmt.Errorf("%w: %v", ErrPackageExpectationInvalid, err)
}
return nil
}
func verifyPackageSHA256(file *os.File, size int64, expectedHash []byte) error {
if file == nil || size <= 0 {
return fmt.Errorf("%w: package handle or size is invalid", ErrInvalidArchive)
}
hasher := sha256.New()
copied, err := io.Copy(hasher, io.NewSectionReader(file, 0, size))
if err != nil {
return fmt.Errorf("%w: read package: %v", ErrInvalidArchive, err)
}
if copied != size {
return fmt.Errorf("%w: got %d bytes while hashing, expected %d", ErrArchiveSizeMismatch, copied, size)
}
info, err := file.Stat()
if err != nil {
return fmt.Errorf("%w: stat package after hashing: %v", ErrInvalidArchive, err)
}
if !info.Mode().IsRegular() || info.Size() != size {
return fmt.Errorf("%w: package changed while hashing", ErrArchiveSizeMismatch)
}
if subtle.ConstantTimeCompare(hasher.Sum(nil), expectedHash) != 1 {
return ErrPackageHashMismatch
}
return nil
}
func readPackageAppManifest(archive *zip.Reader) (packageAppManifest, error) {
var appFile *zip.File
for _, file := range archive.File {
if file.Name != "app.json" {
continue
}
if appFile != nil {
return packageAppManifest{}, fmt.Errorf("%w: duplicate app.json", ErrAppManifestInvalid)
}
appFile = file
}
if appFile == nil {
return packageAppManifest{}, ErrAppManifestMissing
}
reader, err := appFile.Open()
if err != nil {
return packageAppManifest{}, fmt.Errorf("%w: open: %v", ErrAppManifestInvalid, err)
}
document, readErr := io.ReadAll(io.LimitReader(reader, MaxAppManifestBytes+1))
closeErr := reader.Close()
if readErr != nil {
return packageAppManifest{}, fmt.Errorf("%w: read: %v", ErrAppManifestInvalid, readErr)
}
if closeErr != nil {
return packageAppManifest{}, fmt.Errorf("%w: close: %v", ErrAppManifestInvalid, closeErr)
}
if len(document) > MaxAppManifestBytes {
return packageAppManifest{}, ErrAppManifestTooLarge
}
return parsePackageAppManifest(document)
}
func parsePackageAppManifest(document []byte) (packageAppManifest, error) {
if err := validateManifestObject(document); err != nil {
return packageAppManifest{}, err
}
var manifest packageAppManifest
decoder := json.NewDecoder(bytes.NewReader(document))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return packageAppManifest{}, fmt.Errorf("%w: decode: %v", ErrAppManifestInvalid, err)
}
if err := ensureManifestEOF(decoder); err != nil {
return packageAppManifest{}, err
}
if err := manifest.validate(); err != nil {
return packageAppManifest{}, err
}
return manifest, nil
}
func validateManifestObject(document []byte) error {
decoder := json.NewDecoder(bytes.NewReader(document))
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("%w: read object: %v", ErrAppManifestInvalid, err)
}
delimiter, ok := token.(json.Delim)
if !ok || delimiter != '{' {
return fmt.Errorf("%w: root must be an object", ErrAppManifestInvalid)
}
seen := make(map[string]struct{}, len(appManifestFields))
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("%w: read field: %v", ErrAppManifestInvalid, err)
}
name, ok := token.(string)
if !ok {
return fmt.Errorf("%w: field name is not a string", ErrAppManifestInvalid)
}
if _, exists := appManifestFields[name]; !exists {
return fmt.Errorf("%w: unknown field %q", ErrAppManifestInvalid, name)
}
if _, exists := seen[name]; exists {
return fmt.Errorf("%w: duplicate field %q", ErrAppManifestInvalid, name)
}
seen[name] = struct{}{}
var discard json.RawMessage
if err := decoder.Decode(&discard); err != nil {
return fmt.Errorf("%w: read field %q: %v", ErrAppManifestInvalid, name, err)
}
}
if _, err := decoder.Token(); err != nil {
return fmt.Errorf("%w: close object: %v", ErrAppManifestInvalid, err)
}
if len(seen) != len(appManifestFields) {
for field := range appManifestFields {
if _, exists := seen[field]; !exists {
return fmt.Errorf("%w: missing field %q", ErrAppManifestInvalid, field)
}
}
}
return ensureManifestEOF(decoder)
}
func ensureManifestEOF(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", ErrAppManifestInvalid)
}
return fmt.Errorf("%w: trailing data: %v", ErrAppManifestInvalid, err)
}
return nil
}
func (manifest packageAppManifest) validate() error {
if manifest.SchemaVersion != 1 {
return fmt.Errorf("%w: schema_version=%d", ErrAppManifestInvalid, manifest.SchemaVersion)
}
if !packageIDPattern.MatchString(manifest.ID) {
return fmt.Errorf("%w: invalid id", ErrAppManifestInvalid)
}
if manifest.Name == "" || manifest.Vendor == "" {
return fmt.Errorf("%w: name and vendor must not be empty", ErrAppManifestInvalid)
}
if _, err := domain.ParseSemVer(manifest.Version); err != nil {
return fmt.Errorf("%w: version: %v", ErrAppManifestInvalid, err)
}
if manifest.Channel != "stable" || !isSupportedMinOS(manifest.MinOS) {
return fmt.Errorf("%w: channel or min_os", ErrAppManifestInvalid)
}
if manifest.Architecture != "386" && manifest.Architecture != "amd64" {
return fmt.Errorf("%w: architecture=%q", ErrAppManifestInvalid, manifest.Architecture)
}
if _, err := normalizeEntrypoint(manifest.Entrypoint); err != nil {
return fmt.Errorf("%w: %v", ErrAppManifestInvalid, err)
}
if manifest.WorkingDir != "." {
if err := safepath.ValidateRelative(manifest.WorkingDir); err != nil {
return fmt.Errorf("%w: working_directory: %v", ErrAppManifestInvalid, err)
}
}
if !packageIDPattern.MatchString(manifest.ProductID) {
return fmt.Errorf("%w: invalid product_id", ErrAppManifestInvalid)
}
if manifest.DataPolicy != "local-app-data" || manifest.UpdatePolicy != "managed-by-softbox" {
return fmt.Errorf("%w: data_policy or update_policy", ErrAppManifestInvalid)
}
return nil
}
func (manifest packageAppManifest) matches(expectation AppExpectation) error {
if manifest.ID != expectation.ID ||
manifest.Version != expectation.Version ||
manifest.Channel != expectation.Channel ||
manifest.MinOS != expectation.MinOS ||
manifest.Architecture != expectation.Architecture ||
manifest.Entrypoint != expectation.Entrypoint ||
manifest.RequiresAdmin != expectation.RequiresAdmin {
return ErrPackageIdentityMismatch
}
return nil
}
func isSupportedMinOS(value string) bool {
return value == "windows-7-sp1" || value == "windows-10" || value == "windows-11"
}
+242
View File
@@ -0,0 +1,242 @@
package installer
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"testing"
)
func TestExtractorExtractVerifiedFile(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: validAppManifest("1.2.3", "bin/App.exe")},
{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.ExtractVerifiedFile(
archivePath,
destination,
verifiedExpectation(t, archivePath, "1.2.3", "bin/App.exe"),
)
if err != nil {
t.Fatalf("ExtractVerifiedFile() error = %v", err)
}
if result.Files != 2 || len(result.PayloadFiles) != 2 {
t.Fatalf("files = %d, payload files = %d, want 2", result.Files, len(result.PayloadFiles))
}
if result.PayloadFiles[0].Path != "bin/App.exe" || result.PayloadFiles[0].Size != int64(len("executable")) {
t.Fatalf("first payload file = %#v", result.PayloadFiles[0])
}
wantHash := sha256.Sum256([]byte("executable"))
if result.PayloadFiles[0].SHA256 != hex.EncodeToString(wantHash[:]) {
t.Fatalf("first payload hash = %q", result.PayloadFiles[0].SHA256)
}
if _, err := os.Stat(result.EntrypointPath); err != nil {
t.Fatalf("entrypoint stat error = %v", err)
}
}
func TestExtractorExtractVerifiedFileWithCheckPreflightsBeforeStaging(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: validAppManifest("1.2.3", "bin/App.exe")},
{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())
stop := errors.New("pre-extract check stopped")
_, err := extractor.ExtractVerifiedFileWithCheck(
archivePath,
destination,
verifiedExpectation(t, archivePath, "1.2.3", "bin/App.exe"),
func(verified VerifiedPackage) error {
if verified.Entrypoint != "bin/App.exe" {
t.Fatalf("entrypoint = %q", verified.Entrypoint)
}
if verified.PayloadFiles != 2 {
t.Fatalf("payload files = %d, want 2", verified.PayloadFiles)
}
if verified.PayloadBytes != int64(len("executable")+len("hello")) {
t.Fatalf("payload bytes = %d", verified.PayloadBytes)
}
return stop
},
)
if !errors.Is(err, stop) {
t.Fatalf("ExtractVerifiedFileWithCheck() error = %v, want %v", err, stop)
}
var packageErr *PackageError
if !errors.As(err, &packageErr) || packageErr.Stage != PackageStagePreflight {
t.Fatalf("package error = %#v, want preflight stage", packageErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
}
}
func TestExtractorExtractVerifiedFileRejectsBeforeStaging(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: validAppManifest("1.2.3", "App.exe")},
{name: "payload/App.exe", body: []byte("executable"), mode: 0o755},
})
tests := []struct {
name string
modify func(PackageExpectation) PackageExpectation
wantErr error
stage PackageStage
}{
{
name: "hash mismatch",
modify: func(expectation PackageExpectation) PackageExpectation {
expectation.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"
return expectation
},
wantErr: ErrPackageHashMismatch,
stage: PackageStageVerify,
},
{
name: "app manifest identity mismatch",
modify: func(expectation PackageExpectation) PackageExpectation {
expectation.App.Version = "9.9.9"
return expectation
},
wantErr: ErrPackageIdentityMismatch,
stage: PackageStageManifest,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
expectation := test.modify(verifiedExpectation(t, archivePath, "1.2.3", "App.exe"))
_, err := extractor.ExtractVerifiedFile(archivePath, destination, expectation)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, test.wantErr)
}
var packageErr *PackageError
if !errors.As(err, &packageErr) || packageErr.Stage != test.stage {
t.Fatalf("package error = %#v, want stage %q", packageErr, test.stage)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("rejected package left staging, stat error = %v", statErr)
}
})
}
}
func TestExtractorExtractVerifiedFileRejectsStrictAppManifest(t *testing.T) {
manifest := validAppManifest("1.2.3", "App.exe")
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: append(manifest[:len(manifest)-1], []byte(`,"unexpected":true}`)...)},
{name: "payload/App.exe", body: []byte("executable"), mode: 0o755},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractVerifiedFile(
archivePath,
destination,
verifiedExpectation(t, archivePath, "1.2.3", "App.exe"),
)
if !errors.Is(err, ErrAppManifestInvalid) {
t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, ErrAppManifestInvalid)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("invalid manifest left staging, stat error = %v", statErr)
}
}
func TestExtractorExtractVerifiedFileRejectsNonRegularDownload(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractVerifiedFile(
t.TempDir(),
destination,
PackageExpectation{
Size: 1,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
App: AppExpectation{
ID: "test-app",
Version: "1.2.3",
Channel: "stable",
MinOS: "windows-10",
Architecture: "amd64",
Entrypoint: "App.exe",
},
},
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, ErrInvalidArchive)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("non-regular download left staging, stat error = %v", statErr)
}
}
func TestExtractorExtractVerifiedFileBoundsAppManifest(t *testing.T) {
manifest := append(validAppManifest("1.2.3", "App.exe"), bytes.Repeat([]byte(" "), MaxAppManifestBytes)...)
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: manifest},
{name: "payload/App.exe", body: []byte("executable"), mode: 0o755},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, Limits{
MaxEntries: 20,
MaxArchiveBytes: 2 * MaxAppManifestBytes,
MaxCentralDirectoryBytes: 4 * 1024,
MaxUncompressedBytes: 2 * MaxAppManifestBytes,
MaxCompressionRatio: 100,
})
_, err := extractor.ExtractVerifiedFile(
archivePath,
destination,
verifiedExpectation(t, archivePath, "1.2.3", "App.exe"),
)
if !errors.Is(err, ErrAppManifestTooLarge) {
t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, ErrAppManifestTooLarge)
}
var packageErr *PackageError
if !errors.As(err, &packageErr) || packageErr.Stage != PackageStageManifest {
t.Fatalf("package error = %#v, want manifest stage", packageErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("oversized manifest left staging, stat error = %v", statErr)
}
}
func verifiedExpectation(t *testing.T, archivePath, version, entrypoint string) PackageExpectation {
t.Helper()
document, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
hash := sha256.Sum256(document)
return PackageExpectation{
Size: int64(len(document)),
SHA256: hex.EncodeToString(hash[:]),
App: AppExpectation{
ID: "test-app",
Version: version,
Channel: "stable",
MinOS: "windows-10",
Architecture: "amd64",
Entrypoint: entrypoint,
RequiresAdmin: false,
},
}
}
func validAppManifest(version, entrypoint string) []byte {
return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"` + entrypoint + `","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`)
}
+300
View File
@@ -0,0 +1,300 @@
package installer
import (
"encoding/binary"
"fmt"
"io"
"os"
)
const (
endOfCentralDirectorySignature = 0x06054b50
zip64EndSignature = 0x06064b50
zip64LocatorSignature = 0x07064b50
endOfCentralDirectoryLength = 22
zip64EndLength = 56
zip64LocatorLength = 20
maxZIPCommentLength = 1<<16 - 1
maxEOCDSearchLength = endOfCentralDirectoryLength + maxZIPCommentLength
maxInt64 = 1<<63 - 1
)
type endOfCentralDirectory struct {
offset int64
diskNumber uint32
centralDirectoryDisk uint32
entriesOnThisDisk uint64
centralDirectoryEntries uint64
centralDirectorySize uint64
centralDirectoryOffset uint64
}
func (extractor Extractor) openAndScanArchive(
zipPath string,
expectedPackageSize int64,
) (*os.File, int64, error) {
file, size, err := extractor.openArchiveFile(zipPath, expectedPackageSize)
if err != nil {
return nil, 0, err
}
if err := extractor.scanOpenedArchive(file, size); err != nil {
_ = file.Close()
return nil, 0, err
}
return file, size, nil
}
// openArchiveFile opens the completed package once and proves the path still
// names that same ordinary file. Later verification and parsing must retain
// this handle rather than reopening zipPath.
func (extractor Extractor) openArchiveFile(
zipPath string,
expectedPackageSize int64,
) (*os.File, int64, error) {
if err := extractor.limits.validate(); err != nil {
return nil, 0, err
}
if expectedPackageSize <= 0 {
return nil, 0, fmt.Errorf("%w: expected size must be positive", ErrArchiveSizeMismatch)
}
pathInfo, err := os.Lstat(zipPath)
if err != nil {
return nil, 0, fmt.Errorf("%w: inspect package: %v", ErrInvalidArchive, err)
}
if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() {
return nil, 0, fmt.Errorf("%w: package is not a regular file", ErrInvalidArchive)
}
file, err := os.Open(zipPath)
if err != nil {
return nil, 0, fmt.Errorf("%w: open package: %v", ErrInvalidArchive, err)
}
closeWithError := func(err error) (*os.File, int64, error) {
_ = file.Close()
return nil, 0, err
}
info, err := file.Stat()
if err != nil {
return closeWithError(fmt.Errorf("%w: stat package: %v", ErrInvalidArchive, err))
}
if !info.Mode().IsRegular() {
return closeWithError(fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive))
}
if !os.SameFile(pathInfo, info) {
return closeWithError(fmt.Errorf("%w: package changed while opening", ErrInvalidArchive))
}
pathInfoAfterOpen, err := os.Lstat(zipPath)
if err != nil {
return closeWithError(fmt.Errorf("%w: recheck package: %v", ErrInvalidArchive, err))
}
if pathInfoAfterOpen.Mode()&os.ModeSymlink != 0 || !pathInfoAfterOpen.Mode().IsRegular() ||
!os.SameFile(info, pathInfoAfterOpen) {
return closeWithError(fmt.Errorf("%w: package changed while opening", ErrInvalidArchive))
}
size := info.Size()
if size != expectedPackageSize {
return closeWithError(fmt.Errorf(
"%w: got %d, expected %d",
ErrArchiveSizeMismatch,
size,
expectedPackageSize,
))
}
if size > extractor.limits.MaxArchiveBytes {
return closeWithError(fmt.Errorf(
"%w: got %d, limit %d",
ErrArchiveTooLarge,
size,
extractor.limits.MaxArchiveBytes,
))
}
return file, size, nil
}
func (extractor Extractor) scanOpenedArchive(file *os.File, expectedPackageSize int64) error {
if file == nil {
return fmt.Errorf("%w: package handle is nil", ErrInvalidArchive)
}
if err := extractor.limits.validate(); err != nil {
return err
}
info, err := file.Stat()
if err != nil {
return fmt.Errorf("%w: stat package before ZIP scan: %v", ErrInvalidArchive, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive)
}
size := info.Size()
if size != expectedPackageSize {
return fmt.Errorf(
"%w: got %d, expected %d",
ErrArchiveSizeMismatch,
size,
expectedPackageSize,
)
}
if size > extractor.limits.MaxArchiveBytes {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrArchiveTooLarge,
size,
extractor.limits.MaxArchiveBytes,
)
}
return scanCentralDirectory(file, size, extractor.limits)
}
func scanCentralDirectory(file *os.File, size int64, limits Limits) error {
end, err := findEndOfCentralDirectory(file, size)
if err != nil {
return err
}
if requiresZIP64(end) {
if err := readZIP64EndOfCentralDirectory(file, &end); err != nil {
return err
}
}
if end.diskNumber != 0 || end.centralDirectoryDisk != 0 ||
end.entriesOnThisDisk != end.centralDirectoryEntries {
return fmt.Errorf("%w: multi-disk archives are unsupported", ErrInvalidArchive)
}
if end.centralDirectoryEntries > uint64(limits.MaxEntries) {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrTooManyEntries,
end.centralDirectoryEntries,
limits.MaxEntries,
)
}
if end.centralDirectorySize > uint64(limits.MaxCentralDirectoryBytes) {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrCentralDirectoryTooLarge,
end.centralDirectorySize,
limits.MaxCentralDirectoryBytes,
)
}
if end.centralDirectorySize > uint64(maxInt64) ||
end.centralDirectoryOffset > uint64(maxInt64) {
return fmt.Errorf("%w: central directory exceeds int64", ErrInvalidArchive)
}
centralDirectorySize := int64(end.centralDirectorySize)
centralDirectoryOffset := int64(end.centralDirectoryOffset)
if centralDirectorySize > end.offset {
return fmt.Errorf("%w: central directory exceeds end record", ErrInvalidArchive)
}
centralDirectoryStart := end.offset - centralDirectorySize
if centralDirectoryOffset > centralDirectoryStart {
return fmt.Errorf("%w: central directory offset is outside archive", ErrInvalidArchive)
}
baseOffset := centralDirectoryStart - centralDirectoryOffset
if baseOffset < 0 || centralDirectoryStart > size ||
centralDirectoryStart+centralDirectorySize != end.offset {
return fmt.Errorf("%w: central directory bounds are inconsistent", ErrInvalidArchive)
}
return nil
}
func findEndOfCentralDirectory(file *os.File, size int64) (endOfCentralDirectory, error) {
if size < endOfCentralDirectoryLength {
return endOfCentralDirectory{}, fmt.Errorf("%w: archive is shorter than EOCD", ErrInvalidArchive)
}
readLength := int64(maxEOCDSearchLength)
if readLength > size {
readLength = size
}
buffer := make([]byte, int(readLength))
readOffset := size - readLength
if err := readAtExactly(file, buffer, readOffset); err != nil {
return endOfCentralDirectory{}, err
}
for offset := len(buffer) - endOfCentralDirectoryLength; offset >= 0; offset-- {
if binary.LittleEndian.Uint32(buffer[offset:]) != endOfCentralDirectorySignature {
continue
}
commentLength := int(binary.LittleEndian.Uint16(buffer[offset+20:]))
if offset+endOfCentralDirectoryLength+commentLength != len(buffer) {
continue
}
return endOfCentralDirectory{
offset: readOffset + int64(offset),
diskNumber: uint32(binary.LittleEndian.Uint16(buffer[offset+4:])),
centralDirectoryDisk: uint32(binary.LittleEndian.Uint16(buffer[offset+6:])),
entriesOnThisDisk: uint64(binary.LittleEndian.Uint16(buffer[offset+8:])),
centralDirectoryEntries: uint64(binary.LittleEndian.Uint16(buffer[offset+10:])),
centralDirectorySize: uint64(binary.LittleEndian.Uint32(buffer[offset+12:])),
centralDirectoryOffset: uint64(binary.LittleEndian.Uint32(buffer[offset+16:])),
}, nil
}
return endOfCentralDirectory{}, fmt.Errorf("%w: EOCD is missing or malformed", ErrInvalidArchive)
}
func requiresZIP64(end endOfCentralDirectory) bool {
return end.entriesOnThisDisk == 0xffff ||
end.centralDirectoryEntries == 0xffff ||
end.centralDirectorySize == 0xffffffff ||
end.centralDirectoryOffset == 0xffffffff
}
func readZIP64EndOfCentralDirectory(file *os.File, end *endOfCentralDirectory) error {
locatorOffset := end.offset - zip64LocatorLength
if locatorOffset < 0 {
return fmt.Errorf("%w: ZIP64 locator is missing", ErrInvalidArchive)
}
locator := make([]byte, zip64LocatorLength)
if err := readAtExactly(file, locator, locatorOffset); err != nil {
return err
}
if binary.LittleEndian.Uint32(locator) != zip64LocatorSignature ||
binary.LittleEndian.Uint32(locator[4:]) != 0 ||
binary.LittleEndian.Uint32(locator[16:]) != 1 {
return fmt.Errorf("%w: ZIP64 locator is invalid", ErrInvalidArchive)
}
zip64EndOffset, ok := uint64AsInt64(binary.LittleEndian.Uint64(locator[8:]))
if !ok || zip64EndOffset < 0 || zip64EndOffset > locatorOffset-zip64EndLength {
return fmt.Errorf("%w: ZIP64 end offset is invalid", ErrInvalidArchive)
}
zip64End := make([]byte, zip64EndLength)
if err := readAtExactly(file, zip64End, zip64EndOffset); err != nil {
return err
}
if binary.LittleEndian.Uint32(zip64End) != zip64EndSignature {
return fmt.Errorf("%w: ZIP64 end record is invalid", ErrInvalidArchive)
}
recordSize := binary.LittleEndian.Uint64(zip64End[4:])
if recordSize < 44 || recordSize > uint64(locatorOffset-zip64EndOffset-12) {
return fmt.Errorf("%w: ZIP64 end record length is invalid", ErrInvalidArchive)
}
end.offset = zip64EndOffset
end.diskNumber = binary.LittleEndian.Uint32(zip64End[16:])
end.centralDirectoryDisk = binary.LittleEndian.Uint32(zip64End[20:])
end.entriesOnThisDisk = binary.LittleEndian.Uint64(zip64End[24:])
end.centralDirectoryEntries = binary.LittleEndian.Uint64(zip64End[32:])
end.centralDirectorySize = binary.LittleEndian.Uint64(zip64End[40:])
end.centralDirectoryOffset = binary.LittleEndian.Uint64(zip64End[48:])
return nil
}
func readAtExactly(file *os.File, buffer []byte, offset int64) error {
count, err := file.ReadAt(buffer, offset)
if err == nil && count == len(buffer) {
return nil
}
if err == nil {
err = io.ErrUnexpectedEOF
}
return fmt.Errorf("%w: read ZIP metadata: %v", ErrInvalidArchive, err)
}
func uint64AsInt64(value uint64) (int64, bool) {
if value > uint64(maxInt64) {
return 0, false
}
return int64(value), true
}
+300
View File
@@ -0,0 +1,300 @@
package installer
import (
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"
)
func TestExtractorRejectsArchiveSizeBeforeZIPReader(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
size := archiveSize(t, archivePath)
tests := []struct {
name string
limits Limits
expected int64
wantErr error
}{
{
name: "expected size mismatch",
limits: testLimits(),
expected: size + 1,
wantErr: ErrArchiveSizeMismatch,
},
{
name: "expected size is unknown",
limits: testLimits(),
expected: 0,
wantErr: ErrArchiveSizeMismatch,
},
{
name: "archive exceeds raw size limit",
limits: Limits{
MaxEntries: 20,
MaxArchiveBytes: size - 1,
MaxCentralDirectoryBytes: size - 1,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
},
expected: size,
wantErr: ErrArchiveTooLarge,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
test.expected,
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorRejectsCentralDirectoryMetadataBeforeZIPReader(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, data []byte, eocdOffset int)
want error
}{
{
name: "declared entries exceed limit",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint16(data[eocdOffset+8:], 21)
binary.LittleEndian.PutUint16(data[eocdOffset+10:], 21)
},
want: ErrTooManyEntries,
},
{
name: "central directory exceeds limit",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint32(data[eocdOffset+12:], 1025)
},
want: ErrCentralDirectoryTooLarge,
},
{
name: "multi disk archive",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint16(data[eocdOffset+4:], 1)
},
want: ErrInvalidArchive,
},
{
name: "central directory offset is outside archive",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint32(data[eocdOffset+16:], 0xfffffffe)
},
want: ErrInvalidArchive,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
eocdOffset := len(data) - endOfCentralDirectoryLength
test.mutate(t, data, eocdOffset)
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err = extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, test.want) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.want)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorRejectsTruncatedEOCD(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
if err := os.Truncate(archivePath, archiveSize(t, archivePath)-1); err != nil {
t.Fatalf("truncate archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
}
func TestExtractorRejectsMissingOrInvalidZIP64End(t *testing.T) {
tests := []struct {
name string
mutate func(data []byte)
}{
{
name: "missing locator",
mutate: func(data []byte) {
eocdOffset := len(data) - endOfCentralDirectoryLength
data[eocdOffset-zip64LocatorLength] ^= 0xff
},
},
{
name: "invalid record length",
mutate: func(data []byte) {
eocdOffset := len(data) - endOfCentralDirectoryLength
zip64EndOffset := int(binary.LittleEndian.Uint64(data[eocdOffset-zip64LocatorLength+8:]))
binary.LittleEndian.PutUint64(data[zip64EndOffset+4:], 43)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeZIP64TestZIP(t)
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
test.mutate(data)
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err = extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorAcceptsZIP64EndOfCentralDirectory(t *testing.T) {
archivePath := writeZIP64TestZIP(t)
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 1 {
t.Fatalf("Files = %d, want 1", result.Files)
}
}
func TestExtractorRejectsNonRegularArchive(t *testing.T) {
directory := t.TempDir()
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(directory, destination, "App.exe", 1)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
}
func writeZIP64TestZIP(t *testing.T) string {
t.Helper()
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP: %v", err)
}
eocdOffset := len(data) - endOfCentralDirectoryLength
entries := binary.LittleEndian.Uint16(data[eocdOffset+10:])
centralDirectorySize := binary.LittleEndian.Uint32(data[eocdOffset+12:])
centralDirectoryOffset := binary.LittleEndian.Uint32(data[eocdOffset+16:])
zip64EndOffset := len(data) - endOfCentralDirectoryLength
zip64End := make([]byte, zip64EndLength)
binary.LittleEndian.PutUint32(zip64End, zip64EndSignature)
binary.LittleEndian.PutUint64(zip64End[4:], 44)
binary.LittleEndian.PutUint16(zip64End[12:], 45)
binary.LittleEndian.PutUint16(zip64End[14:], 45)
binary.LittleEndian.PutUint64(zip64End[24:], uint64(entries))
binary.LittleEndian.PutUint64(zip64End[32:], uint64(entries))
binary.LittleEndian.PutUint64(zip64End[40:], uint64(centralDirectorySize))
binary.LittleEndian.PutUint64(zip64End[48:], uint64(centralDirectoryOffset))
locator := make([]byte, zip64LocatorLength)
binary.LittleEndian.PutUint32(locator, zip64LocatorSignature)
binary.LittleEndian.PutUint64(locator[8:], uint64(zip64EndOffset))
binary.LittleEndian.PutUint32(locator[16:], 1)
classicEnd := make([]byte, endOfCentralDirectoryLength)
binary.LittleEndian.PutUint32(classicEnd, endOfCentralDirectorySignature)
binary.LittleEndian.PutUint16(classicEnd[8:], 0xffff)
binary.LittleEndian.PutUint16(classicEnd[10:], 0xffff)
binary.LittleEndian.PutUint32(classicEnd[12:], 0xffffffff)
binary.LittleEndian.PutUint32(classicEnd[16:], 0xffffffff)
zip64Data := make([]byte, 0, len(data)+zip64EndLength+zip64LocatorLength)
zip64Data = append(zip64Data, data[:eocdOffset]...)
zip64Data = append(zip64Data, zip64End...)
zip64Data = append(zip64Data, locator...)
zip64Data = append(zip64Data, classicEnd...)
if err := os.WriteFile(archivePath, zip64Data, 0o600); err != nil {
t.Fatalf("write ZIP64 archive: %v", err)
}
return archivePath
}
func assertStagingAbsent(t *testing.T, destination string) {
t.Helper()
if _, err := os.Stat(destination); !os.IsNotExist(err) {
t.Fatalf("rejected archive left staging, stat error = %v", err)
}
}
+14 -1
View File
@@ -64,6 +64,15 @@ func NewInstalledAppStore(appsRoot string) *InstalledAppStore {
return &InstalledAppStore{appsRoot: appsRoot}
}
// EnsureAppRoot creates and validates the real apps/<id> directory used by an
// installer transaction. It does not write an installed-app record.
func (store *InstalledAppStore) EnsureAppRoot(appID string) (string, error) {
store.mu.Lock()
defer store.mu.Unlock()
return store.ensureAppRoot(appID)
}
// Write validates and atomically replaces one installed-app.json.
func (store *InstalledAppStore) Write(record InstalledApp) error {
store.mu.Lock()
@@ -413,7 +422,11 @@ func replaceInstalledAppFile(directory, target, backup string, document []byte)
}
if movedTarget || hadBackup {
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove installed app backup: %w", err)
// The new target is already atomically active. Reporting a cleanup
// failure here would make callers roll back a healthy current
// directory while this record already names the new version. Keep the
// regular recovery backup for a later successful replacement instead.
return nil
}
}
return nil
+4 -2
View File
@@ -21,6 +21,8 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
7. [`tasks/README.md`](tasks/README.md):任务文件约定(一任务一文件);本轮任务从 `docs/tasks/` 领取。
8. [`current-state.md`](current-state.md):当前代码现实、可运行命令、下一步任务。
遇到 `unsafe_cache` 或需要人工处理图标缓存时,只使用 [`troubleshooting.md`](troubleshooting.md) 的不跟随链接 runbook;不要在任务中临时发明递归清理命令。
日常会话不需要机械重读全部文档:
1. 读取仓库级规则(`AGENTS.md`)和 [`agent-context.json`](agent-context.json)。
@@ -45,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
## 当前阶段
当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-608`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线与双适配器交互契约均已关闭;下一步按 Phase 2 交叉审核顺序落成 `VisibleItems` 快照生命周期任务,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。
当前项目已完成 Phase 0~2、T-301~T-303 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core。下一步正式落成并执行 T-401 进程检测与软件启动。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
优先路径:
@@ -53,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
5. 已完成 T-606/T-607/T-608:图标缓存资源边界、Load/Decode→application event→有界 relay/Invalidate→UI ApplyEvent 线程接线,以及双 Gio 适配器的 Editor/Clickable、AppID、viewport、详情上下文与控件生命周期契约;下一步落成 `VisibleItems` 快照任务,再串行处理其余整改与 T-302/T-303、Phase 4-6。
5. 已完成 T-606~T-614:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序和 Catalog 静态签名向量;已完成 T-302:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路。下一步正式落成并执行 T-303,再继续 Phase 4-6。T-601 仍须补真实 Windows 环境的断电/干扰注入。
## 领取任务规则
+21 -13
View File
@@ -59,10 +59,14 @@ UI 固定交互模式:
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。T-609 让每次实际 refilter 在局部新 backing array 完整构造后发布 `VisibleItems` generation,旧 generation 可安全保留到后续帧且每帧读取不复制;返回值严格只读,model 仍由单 owner goroutine 串行操作,不承诺并发安全。
T-610 在两个隔离 `ui/gio` package 内采用相同文件职责:`shell.go` 只保存 AppShell 状态/生命周期和根编排,`shell_header.go` 保存 header/navigation,`shell_catalog.go` 保存 content/list/row/icon/empty state,`shell_detail.go` 保存详情,`shell_style.go` 保存主题与绘制 helper。该拆分没有增加 package/API/状态边界,modern 与 Win7 的 Gio 版本特有布局继续分别实现;适配器交互契约仍负责证明两端事件接线和可见行为一致。
T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。
T-611 固定该链路的 fail-closed 诊断:磁盘 entry 为 symlink/非普通文件时 `IconCache` 不读取、不删除且不 fetch,`IconEventDelivery` 只发布安全分类 `unsafe_cache`,同时把包含 `ErrIconCacheUnsafe` 的错误留给后台调用方。双端 shell 在 UI owner goroutine 保存完整失败 identity + code,只对当前选中软件显示 code/app ID/`<digest>-<dpi>.icon` locator 与人工恢复提示;生命周期与最新请求、reference/DPI、ready、取消和 app 删除绑定。正式安装目标 root 为 `%LOCALAPPDATA%\OwnSoftBox\cache\icons\`,但当前生产 `cmd` 尚未装配 IconCache/Delivery/Fetcher,constructor root 才是代码事实;人工步骤见 [故障排查](troubleshooting.md)。
## 三、仓库目录结构
```text
@@ -79,11 +83,11 @@ soft_quay/
│ └─ updater/ # 盒子自更新编排
├─ app-modern/ # 现代版(go.mod,Go 1.25 + Gio v0.10.1)
│ ├─ cmd/softbox/
│ ├─ ui/gio/
│ ├─ ui/gio/ # shell 根编排 + header/catalog/detail/style 职责文件
│ └─ platform/windows/
├─ app-win7/ # Win7 遗留版(go.mod,Go 1.20 + Gio v0.6.0)
│ ├─ cmd/softbox/
│ ├─ ui/gio/
│ ├─ ui/gio/ # 同名职责文件,保留 Legacy Gio 实现差异
│ └─ platform/windows/
├─ schemas/ # manifest / app.json / files.json / license JSON Schema
├─ scripts/ # 构建、打包、签名、校验脚本
@@ -109,7 +113,7 @@ soft_quay/
- 软件主键是永久稳定的 `id`(小写英文/数字/短横线),不用名称;下架用 `status`,不用名称前缀。
- 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。
- Catalog 正式加载顺序为 HTTPS 获取 → 验签 → 严格字段/Schema/channel 校验 → 缓存替换 → 目标过滤;签名正确但结构或目标通道不匹配的远端内容不能挤掉最后可消费缓存。
- 清单解析拒绝未知字段、重复字段/软件 ID、尾随 JSON、非整数数字、非 HTTPS URL 与 architectures/packages 映射不一致;签名域为移除顶层 `signature` 后的受限规范 JSON,细节见 [api.md](api.md)。
- 清单解析拒绝未知字段、重复字段/软件 ID、尾随 JSON、`-0`/非整数数字、非法 Unicode surrogate、非唯一 Base64 signature、非 HTTPS URL 与 architectures/packages 映射不一致;签名域为只移除顶层 `signature` 的受限规范 JSON。对象键 Unicode 排序、字符串 JSON 转义、原始大整数 token 和静态跨实现向量是同一协议的一部分,细节见 [api.md](api.md)。
- Catalog `entry_exe` 与后续 app.json/files.json 路径必须复用 `core/internal/safepath`,不能由各协议解析器分别维护 Windows 路径规则。
- channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。
- `category` 提供稳定单分类,`tags` 用于搜索和多标签展示;hidden 项从远端目录隐藏,deprecated 与不兼容项保留可见原因但不提供安装包操作。
@@ -123,7 +127,8 @@ soft_quay/
├─ apps/<id>/ # current/ staging/ backup/ installed-app.json
├─ data/<id>/ # 子软件用户数据(更新永不覆盖)
├─ licenses/ # 许可证(更新永不覆盖)
├─ cache/ # 清单缓存、图标缓存
├─ cache/ # 清单缓存
│ └─ icons/ # 正式装配的目标图标缓存根;当前以 NewIconCache(root, ...) 实参为事实
├─ downloads/ # 下载临时文件 + 任务元数据
├─ staging/ # 盒子自更新暂存
├─ backups/ # 盒子自更新备份
@@ -153,7 +158,7 @@ T-301 下载队列:
- cancel 一旦先于 completion 取得线性化点,即使 body close/sync 报错也记录后继续精确清理;若 completion 先完成,后续 cancel 明确拒绝。known-total 完整 part 在同进程 resume/retry 与启动恢复中都直接 finalize,不发送 offset==total 的 Range。
- 写入句柄的文件身份贯穿 sync/close 与 rename 前后核对,防止活跃 `.part` 路径被替换后发布错误文件。崩溃恢复对账 metadata、part、final 三份事实:完整 part 可 finalize,已 rename 的 final 可补 completed metadata;缺 final 的 completed、part+final、超出 expected/unknown 上限均 fail closed。
- 事件投递失败通过 `OnObserverError` 显式报告,不改变 durable transfer 结果;application/UI 启动或重连后用 `Queue.Tasks()` 对账终态,避免 DownloadCompleted 等一次性通知丢失后永久停链。
- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature。
- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 的 `core/application/install.InstallService` 不得把它们当信任根,而是接收已验签/过滤 Catalog `Entry` + architecture,确认其与 `App.Packages[architecture]` 精确对应。它将该 Catalog size/SHA-256 交给 `Extractor.ExtractVerifiedFile`,后者以同一打开的普通完成文件先 size、再 SHA-256、再 ZIP 预扫描/解析;package `signature` 仅由外层 Catalog 签名覆盖,当前没有独立包级验签域。
### 4.3 事件模型
@@ -164,20 +169,23 @@ T-301 下载队列:
安装/更新一款软件的强制顺序:
```text
读取已签名 Catalog → 选择 OS/架构匹配的 Package → 下载到 downloads
→ 校验 size 与 SHA-256 → 安全读取 app.json → 比对 ID/版本/通道/系统/架构
→ 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe
→ 确认目标软件已退出 → current 改名 backup → staging 原子切换为 current
→ 健康检查 → 成功延迟清理 backup / 失败恢复 backup
读取已签名/过滤 Catalog → 选择并交叉核对 OS/架构匹配的 Package → 下载到 downloads
→ 以同一普通完成文件核对实际长度 = Catalog size → 以同句柄校验 SHA-256
→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 有界严格读取根 app.json
→ 比对 ID/版本/通道/系统/架构/入口/管理员标记 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging 并记录实际文件 hash → 校验 entry_exe
→ 恢复旧 transaction(如有)→ current 改名 backup → staging 原子切换为 current
→ 必需 health check → 原子写 installed-app.json → 成功延迟清理 backup / 任一失败恢复 backup
```
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与资源上限,再规划并确认所有 native 输出路径仍在 destination 内,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。core 不包含 Windows API、进程枚举、等待、强杀或启动,具体 Toolhelp 适配与进程退出协议由 T-401 在 `platform/windows`/命令装配时实现。
Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。
该原型已覆盖进程在关键持久化步骤之间退出的恢复;真实断电时的目录项落盘顺序、杀毒软件/文件锁干扰仍需 T-302/T-601 在 Windows VM/真机做故障注入,当前结论不替代硬件级断电验证。
T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度检查后 `Sync`/`Close` → staging 子目录到根及其父目录同步 → prepared journal 的临时文件 `Sync`/`Close` 与 root 栅栏 → 每次目录 rename 的 root 栅栏与下一 phase journal → committed journal 的 root 栅栏 → backup/journal 清理的 root 栅栏。所有栅栏通过 installer 内部接口复用;非 Windows 使用目录 `File.Sync`,Windows 用 Win7 已有的 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 读写目录句柄和 `FlushFileBuffers`,失败一律 fail closed。测试可注入失败并验证状态机保留可恢复 journal,但这仍不是物理掉电证明。
真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-601 在 Windows VM/真机做故障注入;T-302 的代码级链路与单元测试不替代硬件级断电验证。
盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。
+6
View File
@@ -24,16 +24,22 @@
- 依赖方向只允许 `app-modern`/`app-win7` → `core`;任何反向 import 都是返工。
- `core/` 与 `app-win7/` 只使用 **Go 1.20 可编译**的语法与依赖;新增依赖前检查其 go.mod 的 `go` 指令。泛型可用(1.18+),但 1.21+ 的标准库函数(如 `slices`、`maps`、`min/max` 内建)不得进入这两个模块。
- Gio 代码只出现在 `ui/gio/`;Windows 调用只出现在 `platform/windows/`,且必须有非 Windows stub,保证 `go test ./...` 在 Linux CI 可跑。
- modern/Win7 的 Gio shell 都按同 package 镜像职责维护:`shell.go` 只放 AppShell 状态/生命周期和根编排,header/navigation、catalog/list、detail、theme/style 分别进入 `shell_header.go`、`shell_catalog.go`、`shell_detail.go`、`shell_style.go`;版本特有声明留在对应文件,不得为减少重复而跨 workspace 共享不兼容 Gio 代码。
- 仅 Win10+ 存在的 Windows API 必须 LoadLibrary 动态加载、失败降级,不得成为 EXE 导入表强依赖。
- Gio Layout 每帧禁止 IO(磁盘/网络/哈希/图片解码);后台任务只发布 application.Event,不得直接调用 `ApplyIcon` 或改控件/map。后台 event pump 只入有界 relay 并调用 `Window.Invalidate`;只有 Frame/UI goroutine可以 drain `ApplyEvent`。relay 满队列不得静默丢事件,关闭/取消必须解除背压等待。
- `CatalogListModel.VisibleItems()` 返回当前只读 snapshot generation:refilter 只在状态变化时构造新 backing array 后发布,同 generation 的每帧读取不得复制;调用方不得修改 slice/item/Tags。旧 generation 在后续 model 变化后保持稳定,但 model 仍是单 owner、非并发安全对象。
- 图标 Fetcher 必须返回与 context 绑定的流,由 `IconCache` 在分配完整响应前执行声明长度拒绝与 `maxBytes+1` 有界读取;不得恢复为先读任意大 `[]byte` 再校验。缓存并发只允许按 key 去重,不得用横跨磁盘/网络的全局锁换取去重。
- 图标缓存 entry 是 symlink/reparse point 或非普通文件时必须 fail closed:不读取/跟随、不自动 delete/rename/quarantine、不回退 Fetcher。上层只持有已验证的 icon event identity + 稳定 failure code;UI locator 只能由规范 digest + DPI 生成,不得加入绝对 root、raw error、URL/query、token 或 link target。人工处置只引用 [故障排查](troubleshooting.md)。
## 3. 安全纪律(违反即安全事故)
- 任何下载内容未通过 SHA-256 + 签名验证,**不得解压执行**;清单验签失败拒绝,不回退到未验证内容。
- ZIP 与软件包路径必须使用 `core/internal/safepath` 的共享 Windows 安全相对路径策略,拒绝绝对路径、`../`/dot-space 归一化穿越、首尾 ASCII 空格、尾随句点、DOS 设备名、Windows 禁止字符、符号链接逃逸和 entrypoint 指向 payload 外;native 输出路径还必须验证仍在 destination 内。Catalog/storage/app.json/files.json 不得各自复制一套路径规则。
- 任意 `zip.Reader` 构造前,必须以同一普通文件句柄核对已验签 Catalog 的 exact package `size` 与完成下载实际长度,并有界预扫描 EOCD/ZIP64:原始包、中央目录字节数和声明条目数超过硬上限,跨盘/截断/边界不一致元数据一律拒绝;不得先扫描路径后按该路径重新打开,不得仅依赖 `len(archive.File)`。
- Catalog 签名域只移除顶层 `signature`:JSON 解析必须拒绝非法 surrogate、`-0` 和非整数,大整数保持原始十进制 token;signature 必须是唯一的标准 padded Base64 文本,CR/LF/空白或 padding 变体一律拒绝。跨实现测试只消费 `testdata/catalog/canonical-vectors.json` 的静态预期 bytes/公钥/签名,不得用当前 canonicalizer 或测试私钥自举“正确”向量。
- ZIP 解压必须保留文件数、展开体积和压缩比硬上限。
- 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。
- 安装 payload 在 CRC/长度检查后必须 `Sync` 再 Close;staging 目录按子→根及其父目录建立栅栏。journal 临时文件内容、journal/backup rename 或删除、受控目录 rename 或删除后必须同步 app root,成功 phase 不得越过失败栅栏。Windows 目录栅栏使用 Win7 可用的 `FILE_FLAG_BACKUP_SEMANTICS` + `FlushFileBuffers`,不支持或失败必须 fail closed;单元测试不等同于物理断电证明。
- 程序更新不得触碰 `data/` 与 `licenses/`。
- 不强杀用户进程;更新前等待正常退出,超时取消。
- 私钥、真实注册码、真实机器标识不进代码、测试数据和文档;`testdata/` 只放假数据和专用测试密钥对。
+8 -2
View File
@@ -42,11 +42,14 @@
#### Phase 1 交叉审核加固
Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605 关闭前不恢复 T-302;后续中央目录、断电耐久和签名向量任务在前一整改完成并提交后再正式编号。
Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605、T-612、T-613 与 T-614 已关闭;T-613 建立文件、目录和 journal 的代码层耐久顺序,T-614 冻结 Catalog canonicalization/签名静态 corpus 与客户端拒绝规则。T-302 已将这些原型整合到同句柄 Catalog size/SHA、严格 app.json、安全 staging/switch/回滚的安装 use case;物理断电验证保留到 T-601。
| ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
| T-605 | 统一 Windows 安全路径校验并封堵 ZIP 逃逸 | T-102, T-201, T-202, T-604 | Catalog/ZIP/installed-app 共用逐段 Windows 安全相对路径策略;拒绝尾随空格/点与 DOS 设备名;输出路径增加 destination 包含性兜底;原生 Windows 用例证明不写出 staging |
| T-612 | 在 ZIP 打开前限制包大小与中央目录元数据 | T-605 | 已验签 Catalog size、已完成普通下载文件长度与同句柄 EOCD/ZIP64 预扫描一致;在 `zip.NewReader` 前限制原始包、中央目录与声明条目数 |
| T-613 | 建立安装文件与目录事务耐久顺序 | T-612 | payload Sync、staging tree/journal/rename/remove 的目录栅栏;Windows `FlushFileBuffers` fail-closed;物理断电故障注入仍后置到 T-601 |
| T-614 | 冻结 Catalog 规范化与签名跨实现测试向量 | T-613 | 静态 canonical bytes/Ed25519 test vectors 覆盖 Unicode、surrogate、`-0`/大整数、嵌套 signature 与 Base64;客户端不自举期望值,外部发布端可消费同一 corpus |
### Phase 2 · 清单与软件列表
@@ -59,13 +62,16 @@ Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核
#### Phase 2 交叉审核加固
Phase 2 整改按 `docs/review/phase2-review.md` 的交叉复核定稿顺序串行落成。T-606 已关闭正式图标接入前的并发、读取和内存边界;T-607 建立后台图标结果经 application event 回到 Gio UI goroutine 的线程契约;T-608 为两个隔离 Gio 适配器建立交互契约。VisibleItems 快照与 shell 拆分在前一整改完成并提交后再正式编号。
Phase 2 整改按 `docs/review/phase2-review.md` 的交叉复核定稿顺序串行落成。T-606 已关闭正式图标接入前的并发、读取和内存边界;T-607 建立后台图标结果经 application event 回到 Gio UI goroutine 的线程契约;T-608 为两个隔离 Gio 适配器建立交互契约;T-609 修正 `VisibleItems` 快照生命周期;T-610 在不改变行为的前提下拆分双端 Gio shell 职责;T-611 已用真实 fail-closed 传播、双端安全诊断和人工 runbook 关闭最终观察项。
| ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
| T-606 | 收紧图标缓存并发与内存边界 | T-204, T-605 | 按 key in-flight 去重且不同 key 并行;Fetcher 流式有界读取;memory LRU 同时限制字节/条目;modern/win7 删除 app 时剪枝内存 ImageOp |
| T-607 | 建立图标后台结果的 UI 线程事件投递 | T-606 | 后台 Load/Decode 只发布 application event;有界 relay 请求重绘;UI goroutine drain 后 ApplyIcon;过期结果不回写已删除或已换图标的 app |
| T-608 | 建立双 Gio 适配器交互契约 | T-607 | 双端同场景验证 Editor/Clickable 接线、AppID 行身份、详情上下文、500 项 viewport 与控件释放;不重复 ViewModel 纯逻辑 |
| T-609 | 修正 VisibleItems 快照生命周期 | T-608 | refilter 构造新 backing array 后替换;旧快照跨 model 更新保持稳定;每帧读取不复制;明确单 owner 与只读约定 |
| T-610 | 拆分双端 Gio shell 职责 | T-609 | 两端按状态/根编排、header/navigation、catalog/list、detail、style 拆为同 package 镜像文件;行为、事件顺序、视觉与 Gio 隔离不变 |
| T-611 | 建立不安全图标缓存诊断与人工恢复指引 | T-610 | 真实非普通 entry 贯通 unsafe_cache event;双端详情显示无敏感字段的 fail-closed/人工处理提示;不自动删除或 quarantine |
### Phase 3 · 下载与安装
+1
View File
@@ -22,6 +22,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
- [已有项目接入清单](adoption-checklist.md):把本模板补进已有代码库时的迁移步骤(本项目从零开始,备查)。
- [协议合约](api.md):Catalog 清单、标准软件包协议 v1、许可证、事件合约、CLI 参数。
- [页面与视图结构](routes.md):Gio 视图划分、组件归属、交互硬约束。
- [故障排查](troubleshooting.md):`unsafe_cache` 等人工诊断与不跟随链接的安全恢复步骤。
- [当前实现状态](current-state.md):可覆盖的当前快照,记录仓库现实状态、可运行命令和下一步可做任务。
- [Agent 上下文清单](agent-context.md) / [`agent-context.json`](agent-context.json) / [`Schema`](agent-context.schema.json):按任务类型选择文档、用提交 / 文件 SHA 避免重复读取。
- [Gitea MCP 接入](gitea-mcp.md):可选的共享文档、Issue / PR 协调、安全配置和断连降级规则(当前未启用)。
+44 -8
View File
@@ -70,12 +70,12 @@
客户端采用以下签名域,供发布器实现对齐:
1. 输入必须是单个 UTF-8 JSON object;重复字段、尾随 JSON、浮点/指数数字直接拒绝。
1. 输入必须是单个 UTF-8 JSON object;重复字段、尾随 JSON、浮点/指数数字、`-0`、前导零和非法 Unicode surrogate 直接拒绝。JSON 字符串中的 `\u` high surrogate 必须立刻与一个 low surrogate 配对;孤立/不匹配 surrogate 不得替换为 U+FFFD 后继续处理。
2. 读取顶层 `signature`(标准 Base64 编码的 64 字节 Ed25519 签名),然后从对象中移除该字段。
3. 对剩余值递归规范化:对象键按 Unicode 字符串升序排列;数组保持原顺序;字符串按 JSON 转义;数字仅允许 JSON 整数并保持其合法十进制写法;不保留无意义空白。
3. 对剩余值递归规范化:对象键按 Unicode 字符串升序排列;数组保持原顺序;字符串按 JSON 转义;数字只接受 `0`、正整数或负的非零整数,并保持其原始合法十进制 token(包括大于 IEEE-754 安全整数的值);不保留无意义空白。只删除**顶层** `signature`,任何嵌套 package `signature` 仍属于 signed payload。
4. Ed25519 直接签名/验证上述规范 JSON 字节。
T-201 已把该签名域接入正式客户端加载链路并用客户端测试向量覆盖。`softbox-catalog` 发布端仍必须补跨实现向量测试;密钥 ID/轮换字段尚未定稿,在单公钥协议升级前不得另造签名域。
`signature` 文本必须是唯一的标准 padded Base64 表示:严格解码为 64 字节后重新编码必须逐字节等于输入,因此 CR/LF、其他空白、缺失/额外 padding 都拒绝。固定跨实现 corpus 位于 `testdata/catalog/canonical-vectors.json`;客户端与 `softbox-catalog` 发布端必须读取其中的静态 canonical bytes、测试公钥和签名,不得以自身 canonicalizer 重新生成期望值。密钥 ID/轮换字段尚未定稿,在单公钥协议升级前不得另造签名域。
### 1.2 图标内容引用与本地缓存
@@ -93,6 +93,10 @@ Catalog `icon` v1 是 `sha256:<64 hex>` 内容引用,不是可直接请求的 UR
8. `IconEventDelivery` 在调用方拥有的后台 context 中完成 `IconCache.Load` 与 `DecodeIcon`,成功发布 `IconReady`,失败只发布稳定分类的 `IconFailed`;取消直接结束且不发布迟到失败。事件身份为 request_id + app_id + icon_ref + DPI,不得把原始 URL/query 或 Gio 类型放进 payload。
9. application event relay 是有界 FIFO,队列满时执行可取消的 lossless backpressure,不静默丢图标结果。后台 pump 成功入队后只调用并发安全的 `Window.Invalidate`;Gio Frame/UI goroutine 在 Layout 前 drain 并执行 `ApplyEvent`/`ApplyIcon`。
10. shell 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef、DPI 变化或取消请求后丢弃迟到 ready/failed。同一 AppID 更换 IconRef 时先清除旧 ImageOp,Layout 始终只复用内存 `paint.ImageOp`。
11. symlink、目录和其他非普通 cache entry 必须返回 `ErrIconCacheUnsafe` 并立即停止;不得读取/跟随、删除、改写或重命名该 entry,不得回退 Fetcher。该错误由 delivery 映射成唯一稳定码 `unsafe_cache`,后台返回链仍保留 `ErrIconCacheUnsafe` 供调用方分类。
12. shell 只为当前最新身份保存 `IconEventIdentity + IconFailureCode`;仅 `unsafe_cache` 在选中详情显示安全警告、稳定 code、app ID 与 `<digest>-<dpi>.icon` locator。不得显示绝对 cache root、原始 error、URL/query、token 或 link target;人工处理遵循 [故障排查](troubleshooting.md),不提供自动清理/隔离/重试操作。
当前生产 `cmd` 尚未装配 `NewIconCache`、真实 `IconFetcher` 或 `IconEventDelivery.LoadAndPublish` 调用方。以上是已由 core/双端适配器测试冻结的安全契约,不是生产网络图标链已经启用的声明。
## 2. 标准软件包协议 v1(ZIP)
@@ -149,9 +153,13 @@ T-102 Phase 1 原型进一步固定:
- ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。
- 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。
- 拒绝符号链接、设备/管道等特殊文件和加密条目。
- 原型默认上限:10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。T-302 按真实包体分布复核后再冻结。
- T-302 的 `core/application/install.InstallService` 只接收已验签、严格解析并按目标过滤后的 Catalog `Entry` + architecture 与 `.download` 候选路径。它必须确认 entry/package 与 `App.Packages[architecture]` 精确对应;不得从 T-301 task、`DownloadCompleted` payload 或本地 metadata 取得 app/version/size/hash。外层 Catalog Ed25519 签名覆盖嵌套 package 的 `size`、`sha256` 与 `signature` 文本;当前协议未定义 package `signature` 的独立待签名字节/公钥域,客户端不得臆造第二套包级验签。
- `Extractor.ExtractVerifiedFile` 必须接收该 Catalog package 的 `size`、`sha256` 与 app identity expectation。它以一次 `Lstat → open → fstat → Lstat` 取得普通 `.download` 文件,并在**同一打开句柄**上先精确核对 `expectedPackageSize`、再计算/常量时间比较 SHA-256;任一失败时不得构造 ZIP reader、读取 app.json 或创建 staging。T-301 的 known-total 完成文件已经以 Catalog size 限长,但 T-302 仍须执行上述重新对账,不能信任可篡改的下载 metadata。
- 构造 `zip.Reader` 前只读取文件尾部至多 65,557 字节以定位 EOCD,并按需读取固定的 ZIP64 locator/EOCD 记录;校验单磁盘、中央目录 offset/size/entries 的边界及 entries/中央目录大小硬上限。当前默认上限为:原始包 4 GiB、中央目录 64 MiB、10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。大小不一致、原始包超限、中央目录超限、声明条目超限分别保留 `ErrArchiveSizeMismatch`、`ErrArchiveTooLarge`、`ErrCentralDirectoryTooLarge`、`ErrTooManyEntries` 错误链;格式、截断、跨盘或不一致 ZIP64 归入 `ErrInvalidArchive`。
- SHA-256 通过后,预扫描继续使用**同一文件句柄**和已核对的长度创建 `zip.NewReader`;完整中央目录/路径/entrypoint/类型/CRC/展开量预检仍是第二道防线。合法 ZIP64 被支持,不因 32 位 EOCD 哨兵值误拒绝。T-302 按真实包体分布复核上述暂定限额后再冻结。
- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。
- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。
- `app.json` 必须是 ZIP 根目录唯一普通文件,读取上限 1 MiB;严格 JSON 解析拒绝未知字段和尾随值,完整 v1 字段/常量必须通过运行时校验。`id`、`version`、`channel`、`min_os`、`architecture`、`entrypoint`、`requires_admin` 必须与可信 Catalog selection 一致;只有这一比对成功后才可创建 staging 并提取 payload。
### 2.4 安装记录 installed-app.json(本地)
@@ -177,7 +185,7 @@ T-102 Phase 1 原型进一步固定:
规则:
- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、共享 Windows 安全相对路径以外的文件名、大小写折叠重复路径和非法 SHA-256 均拒绝。
- `files` 必须是数组;v1 可以为空,完整文件清单由 T-302 安装整合时从已验证包写入。
- `files` 必须是数组;v1 可以为空。T-302 从实际已验证、CRC/长度检查后写入 staging 的每个 payload 文件计算路径、size 和 SHA-256 并写入完整清单;可选 `files.json` 不是新的信任根,仍保留给后续修复功能。
- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。
- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。
@@ -206,7 +214,35 @@ T-102 Phase 1 原型进一步固定:
phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollback_required`、`committed`。日志使用临时文件 + 同目录 backup 原子替换;恢复时同时检查日志与 current/staging/backup 实际状态。未完成健康检查的 current 不视为可信:有旧版时恢复 backup,首次安装则撤销 current。
### 2.6 下载任务元数据 download-task.json(本地)
T-613 为该原型建立了 fail-closed 的耐久顺序:每个 payload 先完成 CRC/长度检查、`Sync`、`Close`;staging 目录按子目录→staging 根→父目录同步。每次 journal 写入均为临时文件内容 `Sync`/`Close` 后 rename,并在 transaction/backup rename、删除后同步 app root。`current → backup`、`staging → current`、rollback/recovery rename 和受控目录删除也必须先同步 app root,才可写下一 phase 或触发测试步骤;只有 `committed` journal 完成该栅栏后才清理 backup 和 journal。非 Windows 打开目录后 `File.Sync`;Windows 以 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 打开读写目录句柄并调用 `FlushFileBuffers`,任一打开、flush 或 close 失败均返回安装耐久错误,不得静默降级。
T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再原子写入新 `installed-app.json`;health 或记录写失败都必须触发既有 rollback,使旧 current/记录保持可用。只有 health 与记录均成功后才写 committed 并清理 backup/journal。
这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302 已完成代码整合;T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。
### 2.6 安装预检与失败码
在同句柄 size/SHA、ZIP 预扫描和严格 `app.json` 身份比对均通过后,提取器会在创建 `staging/` 前提供已规划 payload 的准确展开字节数、普通文件数和安全 entrypoint。安装 use case 必须以 `payload_bytes + 64 MiB` 查询 app root 所在卷的可用空间;可用空间不足时不创建 staging。预检不能替代写入、同步、切换或回滚阶段的 fail-closed I/O 错误处理。
安装 use case 同时在上述位置检查当前 `current/<entrypoint>` 是否正在运行。容量与运行状态均通过 core 接口注入;检查失败按不可安全继续处理。v1 不强杀、不启动、不等待进程退出。Windows Toolhelp 枚举、正常退出等待与启动协议属于后续 T-401,不能进入 core。
安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。
| code | 含义 |
| --- | --- |
| `hash_mismatch` | 完成文件长度或 SHA-256 与已验签 Catalog selection 不符 |
| `zip_path_escape` | ZIP/app manifest 路径违反共享 Windows 安全相对路径规则 |
| `zip_corrupt` | ZIP 结构、CRC 或受限读取/提取不完整,不能作为有效包 |
| `package_invalid` | package/app manifest 身份或协议不符合可信 selection |
| `disk_full` | 可用空间小于 `payload_bytes + 64 MiB` |
| `disk_check_failed` | 无法可靠取得可用空间或得到非法容量值 |
| `app_running` | 当前目标程序仍在运行,更新不能替换 |
| `target_state_unavailable` | 无法可靠取得目标运行状态 |
| `install_failed` | 其他未细分的安装、健康、记录、切换或回滚失败 |
上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。
### 2.7 下载任务元数据 download-task.json(本地)
每个任务以稳定 `request_id` 为主键,元数据位于 `downloads/tasks/<request_id>.json`,字节文件位于 `downloads/files/<request_id>.part|.download`。本地路径只由客户端从 request_id 派生,不接受 URL 或 Content-Disposition 提供的文件名。
@@ -240,7 +276,7 @@ phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollba
- known total 的 `.part` 已达到精确 total 时,同进程 resume/retry 与重启恢复都直接 sync、核对身份并 finalize,不得再请求 `Range: bytes=<total>-`。
- 完成顺序为 part sync/close → 核对实际写入文件身份 → rename `.download` → metadata completed → DownloadCompleted。rename 前后必须仍是同一普通文件;恢复时以普通文件实际长度对账,不信任 metadata.done。
- 事件投递是 best-effort,失败不得反向把已完成/已取消的持久任务改成失败。队列必须把投递错误交给 `OnObserverError` 记录;application/UI 在启动或事件通道重连后必须用 `Queue.Tasks()` 对账持久状态,不能只依赖某一次终态事件。
- `.download` 仍是**不可信隔离字节**。T-302 必须重新从已验签 Catalog 取得 app/version/arch/size/SHA-256/signature 并验证,通过后才可读取 app.json 或解压。
- `.download` 仍是**不可信隔离字节**。T-302 必须重新从已验签/过滤 Catalog 取得并交叉核对 app/version/arch/size/SHA-256(及已由 Catalog 解析器校验格式、被外层签名覆盖的 package signature 文本),在同一普通文件句柄完成 size+SHA-256 后才可读取 app.json 或解压。
## 3. 许可证(服务端签发 → 本地离线验证)
@@ -284,7 +320,7 @@ phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollba
| IconReady | 图标已完成可信加载与后台解码 | request_id, app_id, icon_ref, DPI, image.Image | 有界 relay 唤醒窗口;UI Frame 验证仍为最新请求后创建 ImageOp |
| IconFailed | 图标加载、校验或解码失败(取消不发布) | request_id, app_id, icon_ref, DPI, error_code | UI 记录诊断;仅保留同 icon_ref/DPI 的既有可信图标,否则继续占位 |
错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。图标事件只使用 `unavailable`、`invalid_content`、`unsafe_cache`,原始网络错误只返回后台调用方/日志,不得进入 UI payload。
错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。图标事件只使用 `unavailable`、`invalid_content`、`unsafe_cache`,原始网络错误只返回后台调用方/日志,不得进入 UI payload。`unsafe_cache` 详情只使用 event 中已经验证的 app ID、icon_ref 与 DPI 生成 `<digest>-<dpi>.icon` locator;不得把 cache root、原始错误或 link target 补进 event/UI。
## 5. CLI 参数合约
+11 -11
View File
@@ -12,27 +12,27 @@
## 当前快照
- 日期:2026-07-17
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-608 已完成,T-302 继续暂后置
- 日期:2026-07-18
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合与 T-303 失败处理/磁盘预检查已完成;审核整改 T-604~T-614 已完成;下一步应正式落成并执行 T-401
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,失败返回稳定 code 且不触发 switch;每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:无;下一步按 `docs/review/phase2-review.md` 最终顺序落成 `VisibleItems` 快照生命周期任务;Phase 1 中央目录预扫描等继续串行,T-302 继续后置
- 当前 blocker:无;T-614 已以静态 corpus 冻结客户端 Catalog canonicalization/签名行为,外部 `softbox-catalog` 消费 corpus 的 CI 证据仍需跨仓库协调,但不阻止 T-303。物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-608 已完成;其余审核整改尚未编号,T-302 暂后置 |
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303 与 T-604~T-614 已完成;下一步按路线图落成 T-401 进程检测与软件启动任务 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp |
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并保持同名职责文件与版本差异 |
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` |
| `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 |
@@ -40,9 +40,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-608`。
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303`;审核整改 `T-604`~`T-614`。
- 正在进行:无。
- 下一个可领取任务:无;先按 `docs/review/phase2-review.md` 最终顺序落成 `VisibleItems` 快照生命周期任务。
- 下一个可领取任务:暂无;应按 Phase 4 路线图先将 T-401 进程检测与软件启动正式落成任务文件,再领取。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
## 当前可运行内容
+23 -3
View File
@@ -200,11 +200,31 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题
### 最终处理顺序(定稿)
1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。
2. **[阻断 T-302]** `zip.OpenReader` 前增加包大小与中央目录/EOCD 预扫描边界;与 Catalog `size`、下载完成长度三者一致,不只依赖 `len(archive.File)`。
3. **[发布前必做]** 完整断电耐久策略:payload 文件 Sync → staging 目录元数据 → journal 顺序点 → 每次 rename 后父目录顺序点 → committed 耐久后才删 backup/journal;评估 Windows 目录句柄 `FlushFileBuffers`/写穿方案;T-302/T-601 用 VM/真机断电注入验证。
4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。
2. **[已关闭 · T-612]** 已在构造 `zip.Reader` 前增加同句柄包大小与中央目录/EOCD(含 ZIP64)预扫描边界;Extractor 强制接收 expected Catalog `size`,核对打开文件长度并限制原始包、中央目录与声明条目数,不只依赖 `len(archive.File)`。T-302 仍须把已验签 Catalog、完成下载文件和 SHA-256 编排为真实安装调用链。
3. **[代码顺序已关闭 · T-613;发布前环境验证仍必做]** payload 文件 Sync/Close → staging 子目录、根与父目录元数据栅栏 → journal 临时文件 Sync/Close 与 root 栅栏 → 每次 rename 后 root 栅栏 → committed 耐久后才删 backup/journal。Windows 已使用 Win7 可用的目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`,失败 fail closed;fake 覆盖 payload/tree/journal/rename/rollback/recovery/cleanup 失败,且原生 Windows 用例验证目录 fence 可执行。T-302/T-601 仍必须在 VM/真机进行真实断电、文件锁/杀毒干扰注入,不能以单元测试替代。
4. **[客户端协议已关闭 · T-614;外部发布端验证待协调]** 已冻结静态 corpus 与客户端 canonicalization/签名拒绝规则:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate、`-0`/大整数/嵌套 signature、Base64 padding/CR/LF/空白、同语义不同表示同 signing bytes。`softbox-catalog` 外部仓库仍必须消费相同 corpus 并提供独立 CI 证据;本仓库没有其源码,不能将客户端回归测试表述为发布端集成已完成。
5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。
6. **[实现时重点审]** T-402/T-403 真实健康检查:防"进程短暂启动即判健康"、错误工作目录/错误二进制被探活。
7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。
> 裁定:Codex 二次复核成立且纠正了原审核的绝对化表述与一处过松建议;M2 调整为"有条件成立"(T-101/T-103 原型目标成立;T-102 须先关闭 Windows 路径语义缺口才能作为正式安装链路可信基线)。上述顺序为双方交叉复核后的共识。
### T-612 完成记录(2026-07-18)
- `Extractor.ExtractFile` 改为显式接收 `expectedPackageSize`,先打开同一普通文件,以其真实长度核对 Catalog 值和 4 GiB 原始包上限,再以该句柄构造 `zip.NewReader`;没有“预扫 path 后重开 path”的替换窗口。
- 新的 EOCD 预扫描只读取最多 65,557 字节尾部和固定 ZIP64 locator/end 记录,拒绝跨盘、截断、offset/size 溢出或不一致结构,在标准库解析中央目录前限制 64 MiB 中央目录及 10,000 个声明条目。现有完整 `preflight` 保留为第二道 ZIP 语义和展开数据防线。
- installer 回归覆盖 size 不一致、原始包超限、经典 EOCD 条目/目录伪造、跨盘/截断、ZIP64 有效和损坏 locator/end、非普通输入与 staging 未创建;`GOWORK=off go vet ./installer` 和 `go test -count=10 ./installer` 均通过。
- 关闭的是 Extractor 边界,不是 T-302 生产安装整合或真实下载信任链;下一项断电耐久性整改仍独立阻断 T-302。
### T-613 完成记录(2026-07-18)
- `Extractor` 在每个 payload 的 CRC/长度检查后同步并关闭文件,随后从 staging 子目录到根、再到 staging 父目录执行栅栏;任一失败删除本次 staging,不返回可安装结果。
- transaction、Switcher、rollback、Recovery 和受控删除统一经内部耐久接口:journal 临时文件先同步内容,所有 journal/目录 rename 或删除后同步 app root,未完成栅栏不得推进测试步骤或下一 phase。清理路径同时保留 `ErrRecoveryRequired` 与底层耐久错误,以便调用方可判定恢复状态。
- Windows 原生目录测试已验证读写目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`;Go 1.20 标准 `syscall` 路径不引入 Win10+ API 或第三方依赖。fake 覆盖 payload/tree、journal、switch、rollback/recovery 与 committed 清理的错误传播和后续 Recover 不变式。
- 关闭的是代码中的耐久顺序与 fail-closed 策略;T-302/T-601 的 Windows VM/真机物理断电、文件锁与杀毒软件故障注入仍为发布前环境验证,不宣称当前单元测试提供硬件级保证。
### T-614 完成记录(2026-07-18)
- 新增 `testdata/catalog/canonical-vectors.json` v1:公开 RFC 8032 测试公钥、固定原始 document、固定 canonical signing bytes 和 Ed25519 signature 均为静态数据。测试先比较 bytes,再用静态公钥验证静态 signature,不调用客户端 canonicalizer 或运行时私钥生成期望值。
- corpus 覆盖 Unicode 键排序/转义、`<>&`、U+2028/U+2029、合法 pair 和三类非法 surrogate、`-0`、大整数、嵌套 `signature`、标准 padding、CR/LF/space/tab、padding 缺失/额外以及两种语义相同 JSON 表示。
- 客户端现在在 JSON decoder 前拒绝孤立/不匹配 surrogate,拒绝 `-0`;Verifier 和 Parser 共享标准 padded Base64 的 decode→reencode 相等检查。关闭的是客户端协议契约;外部 `softbox-catalog` 消费 corpus 的实现/CI 证据仍需跨仓库协调。
+3 -1
View File
@@ -286,4 +286,6 @@ modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取
- `T-606` 已完成最终处理顺序第 1 项:按 key in-flight 去重、Fetcher 流式有界读取、32 MiB/256-key memory LRU 与 modern/win7 `shell.icons` 剪枝已实现并通过完整双 workspace 闸门。
- `T-607` 已完成最终处理顺序第 2 项:后台图标 Load/Decode 只发布强类型 application event,有界 FIFO relay 无损背压并请求重绘,由 Gio Frame/UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`;最新请求、删除 app、IconRef/DPI 变化和取消都阻断迟到结果回写。core 与双 workspace 定向测试及完整闸门通过。
- `T-608` 已完成最终处理顺序第 3 项:modern/win7 使用除 edition/窗口尺寸外一致的场景矩阵,验证 Editor/Clickable 经 Layout 更新共享 model、重排后 AppID 行身份、详情关闭上下文、500 项 viewport、app/category controls 释放及两类空状态语义;双端定向重复测试与完整闸门通过,生产 `shell.go` 无需修正。
- `VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;下一任务从 `VisibleItems` 快照生命周期开始,继续按顺序串行落成。
- `T-609` 已完成最终处理顺序第 4 项:`refilter` 在局部新 backing array 完整构造后发布,旧 generation 跨五类公开 model mutation 保持稳定;同 generation 读取共享 backing 且零分配。core 定向重复测试、双 Gio 回归与完整闸门通过,未扩展为并发安全或防御性深拷贝。
- `T-610` 已完成最终处理顺序第 5 项:双端 `shell.go` 只保留 AppShell 状态/生命周期和根编排,其余声明进入 header/navigation、catalog/list、detail、style 同 package 镜像职责文件;原 54 个声明与根调用链保持不变,双端适配器/图标回归及完整闸门通过。
- `T-611` 已完成最终处理顺序第 6 项:真实目录与 Windows symlink cache entry 均证明 fail closed、零 fetch 且不改写 entry/target;真实 cache→delivery 唯一发布 `IconFailed/unsafe_cache` 并保留后台 sentinel。双端保存完整失败 identity,只在当前详情显示安全 code/app/locator 与人工 runbook,生命周期/语义树、定向重复测试及完整闸门通过;自动 quarantine 仍需独立威胁模型。
+188
View File
@@ -0,0 +1,188 @@
# Phase 3 审查(T-301 ~ T-303:下载队列 / 安装整合 / 失败处理)
> 审查范围:`8dad40f`(T-301 可恢复下载队列,早前已实现)、`14589ab`(T-302 安装流程整合)、`ae3f64c`(T-303 失败处理与磁盘预检)。
> 本轮深读重点:T-302/T-303 的安装整合与失败处理(`core/installer/verified_package.go`、`core/application/install/service.go`);T-301 队列此前已在架构文档层核过(可恢复、崩溃对账),本轮不重复深挖。
> 审查视角:全栈开发工程师 + 安全审计。
> 审查方式:静态代码审计 + 攻击路径推演。因 WSL 无 Go,未执行测试。
> 日期:2026-07-19
## 总体判断
**Phase 3 质量高,安全正确,未发现安全缺陷。** 安装整合把 Phase 1 的原型(验签清单、安全解压、原子切换)串成完整可信链,并且在两个最容易被忽略的点上做对了:**先验哈希再解析 ZIP**、**全程复用同一 file 句柄防 verify-then-use TOCTOU**。失败处理有完整的稳定错误码枚举。M3(清单→列表→下载→安装→启动)核心闭环成立。以下均为可优化观察项,非缺陷。
## 逐模块核验
### T-302 · 安装整合(installer/verified_package + application/install/service)
**包验证链顺序正确(`verified_package.go`,对应 api.md 安装验证顺序):**
1. `expectation.validate()`:Catalog 期望校验(size>0、SHA-256 32 字节、app 字段合法)。
2. `openArchiveFile(zipPath, expectation.Size)` → **`verifyPackageSHA256` 在解析任何 ZIP 结构之前**用 `io.NewSectionReader` 全量哈希并 `subtle.ConstantTimeCompare`;哈希后 `Stat` 复查 `Size`/`IsRegular` 防并发改动。
3. `scanOpenedArchive`(中央目录预扫描)→ `zip.NewReader` → `preflight`(路径/上限/结构)→ `readPackageAppManifest`(app.json,限 1 MiB)→ `manifest.matches(expectation.App)`。
4. `beforeExtract`(环境预检)→ `extractPlan`(安全解压)。
**两个关键安全属性:**
- **哈希先于解析**:恶意 ZIP 在 SHA-256 通过前碰不到 zip parser,消除"解析未验证字节"的攻击面。
- **单句柄贯穿**:同一个 `file` 句柄用于哈希 → 预扫描 → `zip.NewReader` → 解压,攻击者无法在"验证后、使用前"替换文件(verify-then-use TOCTOU)。这是很多实现漏掉的点。
**app.json 严格且与 Catalog 交叉比对:** `validateManifestObject` 手工拒绝未知/重复/缺失字段与尾随数据;`DisallowUnknownFields` 二次解码;`validate()` 固定 `schema_version=1`、`channel=stable`、`data_policy`/`update_policy` 定值、`working_directory` 经 safepath;`matches()` 要求 ID/版本/通道/min_os/架构/entrypoint/requires_admin **全部等于已签名 Catalog 期望**。
**`VerifiedPackage` 不含 ZIP 句柄或目标路径**,预检回调拿不到原始 archive/destination,无法绕过安全解压——好的 API 边界。
**每文件 SHA-256 在解压流中计算**(`io.MultiWriter(output, digest)`,无二次读),写入 `installed-app.json` Files,为后续 files.json 修复铺路。
### T-302 编排(`install/service.go`)
- **信任边界清晰**:`InstallRequest` 明确"untrusted completed download + trusted Catalog selection";信任根是 Catalog 的 `Size`/`SHA256`。本地对 `.download`/元数据的篡改被 SHA-256 + 签名复检拦下。
- **先恢复再安装**:`installer.Recover(appRoot)` 在开始新安装前解决遗留事务,与 switcher 的 `ErrRecoveryRequired` 守卫一致。
- **强制安全依赖**:`NewInstallService` 拒绝 nil 的 Records/Health/DiskSpace/TargetState——"no caller can silently bypass the pre-extract safety boundary"。
- **record 写入置于 switcher 健康回调内**:写失败与健康失败走同一回滚路径(`recordWriteErr` 追踪 → `InstallStageRecord`),避免"切换成功但记录半写"的中间态。
### T-303 · 失败处理与磁盘预检(`preExtractCheck` + 错误码)
- **磁盘预检**:`required = payloadBytes + 64 MiB reserve`,`available < required` 或 `< 0` 拒绝;在解压目标创建前 fail-fast。
- **运行检测**:`TargetState.IsRunning` 在预检拒绝正在运行的目标(`ErrTargetRunning`);接口注释明确"never starts/waits/terminates a process",符合"不强杀"。
- **稳定错误码枚举**:`hash_mismatch`/`zip_path_escape`/`zip_corrupt`/`package_invalid`/`disk_full`/`disk_check_failed`/`app_running`/`target_state_unavailable`/`install_failed`,`failureCodeFor` 用 `errors.Is` 映射;UI 只本地化码、不显原始错误。与 api.md 错误码一致,满足"各情况有确定结果与错误码"。
## 需优化项(按优先级,均非安全缺陷)
### O1 · 运行检测存在 TOCTOU,switch 前不复查
**现状:** `IsRunning` 只在 `preExtractCheck`(解压前)执行;`switcher.Switch` 在 `current → backup` rename 前**不复查**。解压可能耗时数秒,期间用户可能启动该软件。
**影响:** Windows 对运行中 EXE 的文件锁能否可靠阻止**父目录** rename,并无明确保证(取决于镜像 section 锁语义),不应默认"Windows 一定会挡住"。两种劣化路径:① rename 失败 → 回滚(较干净,但错误码是通用 switch 失败而非 `app_running`);② rename 成功但 commit 阶段删 backup 因运行中 EXE 锁失败 → 新版已装但 backup 滞留 + 报错。均非数据丢失,但都不干净;非 Windows(测试)路径无锁,TOCTOU 更实。
**建议:** 在 switcher `current→backup` **紧邻前**再查一次 `IsRunning`(把窗口从"整个解压期"收窄到"切换瞬间");并确保"因占用导致 rename 失败"映射到清晰的 `app_running` 诊断而非通用错误。TOCTOU 无法在无 OS 级锁下完全关闭,但可显著收窄。
### O2 · 磁盘预检是建议性,真正保证来自解压失败清理
**现状:** `AvailableBytes` 在解压前查,但其他进程可在"查"与"写"之间占用磁盘。
**影响:** 中途 ENOSPC 由 extractor 的失败清理(删 staging)兜底,只是错误路径与预检的 `disk_full` 码不同。预检是 fail-fast UX,不是保证。
**建议:** 明确文档表述"预检为 fail-fast,真正的原子性由 extractor 失败清理保证";可选:把中途 ENOSPC 也归一到 `disk_full` 码,避免同一现象两种码。
### O3 · 已完成下载文件的生命周期归属未在安装服务体现
**现状:** `Install` 不删除 `request.DownloadPath`;安装成功后 ZIP 由谁清理未在此层体现。
**建议:** 明确下载 ZIP 的清理归属(下载队列 or 专门 GC),避免 `downloads/` 无限增长。属生命周期职责,非缺陷。
### O4 · verified_package 的 stage 标签命名不一致
**现状:** ZIP `preflight()` 失败标记为 `PackageStageVerify`,而 `PackageStagePreflight` 用于 `beforeExtract`;`normalizeEntrypoint` 失败标 `PackageStageManifest`。
**影响:** 纯观测性命名,不影响安全或行为;诊断时 stage 语义略含糊。优先级低。
### O5 · StagingDiskReserveBytes 固定 64 MiB
**现状:** 固定预留,不随包大小缩放。更新场景峰值磁盘为"旧 current + 新 staging 并存"(current→backup、staging→current 均为同卷 rename,无额外空间),预检只保证新 payload+reserve 的空间,假定旧 current 已在盘。
**影响:** 该假定对更新/首装都成立,heuristic 合理。仅提醒:若未来 backup 策略改变(非同卷 / 复制而非 rename),需重估预留模型。
## 结论
Phase 3 可作为启动/更新/授权阶段的可信安装基线。安装整合的安全属性(哈希先于解析、单句柄防 TOCTOU、app.json 与 Catalog 交叉比对、untrusted 下载 + Catalog 信任根、强制不可绕过的预检)都到位,失败码体系完整。建议处理顺序:
1. **[可靠性]** O1:switch 前复查 `IsRunning` 收窄 TOCTOU,并把占用导致的 rename 失败映射到 `app_running` 码。
2. **[一致性]** O2:统一中途 ENOSPC 与预检的 `disk_full` 码;文档写清预检为 fail-fast。
3. **[生命周期]** O3:明确已完成下载 ZIP 的清理归属。
4. **[低优先]** O4/O5:统一 package stage 命名;预留模型随 backup 策略复核。
> 声明:本报告为静态审计与攻击路径推演,逐项核了实现(含 `verifyPackageSHA256` 单句柄链、`io.MultiWriter` 每文件哈希、`preExtractCheck` 强制性、`failureCodeFor` 映射);因 WSL 无 Go 未执行测试,双 workspace 闸门通过为 Codex 自述。O1 的 Windows 文件锁语义需真机验证。
## Codex 复核修正(2026-07-19)
本节保留上文 Claude Code 的静态审计结论,并根据当前代码、任务边界和路线图作出补充/修正;没有修改 T-301~T-303 的实现。复核时额外执行了 `go -C core test -count=1 ./installer ./application/install`,两个包均通过。
### 修正后的总体判断
上文对 T-302/T-303 的核心信任边界判断成立:已验签 Catalog selection、同一普通文件句柄上的 size/SHA-256、ZIP/app manifest 验证、安全 staging、switch/rollback 以及 staging 前环境预检的顺序正确,未发现从未验证下载字节到执行/切换的直接绕过路径。
但“`M3(清单→列表→下载→安装→启动)核心闭环成立`”不成立。路线图把 M3 定义为 Phase 2~4 前半的闭环,而 T-401 的 Toolhelp 进程检测、启动前检查、WorkingDirectory 与实际启动尚未落成;当前 `NewInstallService`/`TargetStateChecker` 只在测试中构造或调用,尚无命令层生产装配。因此准确表述应为:**T-302/T-303 的无头 core 可信安装边界已完成;端到端安装编排和启动闭环尚未完成。**
同理,“Phase 3 未发现安全缺陷”应限定为“本轮深审的 T-302/T-303 未发现新的信任边界绕过”。本报告明确未重新深审 T-301,不能据此替代对整个 Phase 3 的完整安全结论。
### O1 · 接受,并转入 T-401 的切换临界区契约
`TargetStateChecker.IsRunning` 目前只在解压前执行;解压完成到 `Switcher` 的 `current → backup` 之间存在时间窗口,用户可在此期间启动旧版本。该观察成立,优先级应为 P1 可靠性整改。
处理方式不应把所有 rename 失败一概映射为 `app_running`:权限、杀毒软件、目录损坏等错误会产生错误归因。应在 `current → backup` 的紧邻前增加可注入的最后一次运行状态检查;只有该检查明确返回“仍在运行”时才返回 `app_running`。T-401 落成时应显式消费 T-303 的 `TargetStateChecker` 契约,并补充“解压期间启动旧版”的测试;Windows 文件锁/映像节语义仍需 T-601 真机/VM 验证。
### O2 · 提升为 P1:中途磁盘写满会误报且丢失根因
上文正确指出磁盘预检只是 fail-fast 建议,不能消除“查询后被其他进程占满”的窗口;`docs/api.md` 已经写明预检不能替代写入、同步、切换和回滚阶段的 fail-closed I/O 处理。
但现状比“中途 ENOSPC 变成通用 switch 失败”更严重:`core/installer/extractor.go` 的 `io.Copy` 失败统一返回 `ErrArchiveCorrupt`,并把原始 `copyErr` 作为 `%v` 格式化文本而非 `%w` 错误链。若目标文件写入返回 ENOSPC,调用方会得到 `zip_corrupt`,且不能用 `errors.Is` 识别原始 I/O 根因;这与 T-303 对稳定错误码和可识别根因的目标不符。
整改应同时做到:区分 ZIP 输入/CRC 读取失败与 staging 输出写入失败、保留原始 error chain、把可识别的“磁盘已满”归一为 `disk_full`。跨 Windows/非 Windows 的磁盘满判定不得在 core 直接依赖 Windows API;应由明确的、可测试的接口或平台适配提供分类。`os.RemoveAll(destinationRoot)` 当前是忽略错误的尽力清理,旧版本安全来自尚未进入 switch,而不是清理本身保证原子性;清理失败的恢复语义也应在整改中明确。
### O3 · 接受,但属于后续编排/生命周期任务
已完成 `.download` 的生命周期目前没有生产消费者:下载任务注释只说明 completed 文件“等待后续 workflow consume/remove”,`InstallService` 只接收候选路径且不应删除任意外部路径。应在正式安装编排中定义安全消费协议:仅安装成功后,由下载队列或以 `request_id` 派生路径的编排层删除完成文件和任务元数据;安装失败保留文件用于重试/诊断。该项不应通过让 `InstallService` 直接删除 `DownloadPath` 来解决。
### O4 · 不建议作为独立整改项
`PackageStageVerify` 覆盖 ZIP 的可信性/安全验证,而 `PackageStagePreflight` 专指验证完成后的环境 hook;二者语义可区分,当前错误码也依赖根因而非 stage。命名略有认知成本,但不影响安全、行为或用户可见码。除非后续观测系统需要更细粒度指标,否则不应为此单独改动稳定 stage API。
### O5 · 接受,维持为低优先级架构假设
当前 app root 内的 `current → backup` 与 `staging → current` 都是同卷 rename;旧 current 与完成下载包已占用的空间会反映在预检时的可用空间中,额外需求为新 payload 加 64 MiB 是合理的 v1 启发式。若未来改成跨卷复制、保留多份 backup 或改变下载/安装卷布局,再重新评估该模型。
### 建议的后续顺序
1. 在下一次正式整改前,先落成任务规格,关闭 O2 的输出 I/O 错误保留与 `disk_full` 分类问题。
2. 落成 T-401 时,把运行状态适配、启动协议和 O1 的切换临界区复查作为同一契约实现,并将 T-303 明确列为其依赖/接口前提。
3. 在下载完成到安装的生产编排任务中,定义 O3 的 completed 文件消费和失败保留规则。
## 交叉复核裁定(定稿)
> 复核视角:Claude 全栈开发工程师,对 Codex 复核修正逐条核验后裁定。
> 核验方式:grep `NewInstallService` 生产调用者;读 `extractor.go` 第 203-235 行 io.Copy/fsync/close 的错误包装与码映射;读 `06-tasks.md` M3 定义;因 WSL 无 Go,未执行测试。
> 日期:2026-07-19
### 承认原审核错误
本轮 Codex 复核比原审核更准。原审核有两处 overstate 与一处有缺陷建议,应更正:
- **"M3 核心闭环成立" 撤回——原审核错。** `NewInstallService` 无任何生产调用者(仅定义/测试);M3 路线图定义含"启动(Phase 2-4 前半)",而 T-401(Toolhelp 进程检测 + 启动)未落成。准确表述:**T-302/T-303 的无头 core 可信安装边界已完成;端到端编排与启动闭环未完成。**
- **"Phase 3 未发现安全缺陷" 应限定为 T-302/T-303。** 本轮未重新深审 T-301,不能替代整个 Phase 3 的安全结论。
- **原 O2 定性错误(位置 + 严重性)。** ENOSPC 发生在**解压阶段的 `io.Copy` 写**,不是"通用 switch 失败";它被包成 `ErrArchiveCorrupt` + `%v` → 映射为 `zip_corrupt`(误导为"包坏了")且断了 error chain。这直接违反 T-303 "可识别根因"目标。O2 升 P1 成立。
- **原 O1 建议有缺陷,收回。** "把占用导致的 rename 失败映射到 `app_running`" 会错误归因(权限/杀软/目录损坏也会致 rename 失败);正确做法是 switch 紧邻前**显式复查 `IsRunning`**,只有其返回运行才报 `app_running`。
### 已确认的事实(可作为后续任务前提)
| 项 | 结论 | 证据 |
| --- | --- | --- |
| InstallService 无生产装配 | 属实(M3 撤回) | `NewInstallService` 仅 `service.go:129` 定义,无非测试调用者 |
| M3 含"启动"且未完成 | 属实 | `06-tasks.md:114` M3=「…安装→启动」Phase 2-4 前半;T-401 未落成 |
| ENOSPC 误分类为 zip_corrupt | 属实(O2 升级) | `extractor.go:210` io.Copy 失败包 `ErrArchiveCorrupt`+`%v`;`failureCodeFor` → `zip_corrupt`;消息误标 "read" |
| 磁盘满有三种落点三种码(本轮新增) | 属实 | write→`zip_corrupt`(210);`syncFileWithFence`→默认 `install_failed`(230-231);`Close`→默认 `install_failed`(235)。ENOSPC 常延迟到 fsync/close 暴露 |
| RemoveAll 忽略错误 | 属实 | 失败清理 `_ = os.RemoveAll(...)`;旧版本安全来自"尚未进 switch"而非清理原子性 |
### 采纳 Codex 的修正与 sharpen
- **接受** M3 表述撤回、"无安全缺陷"限定到 T-302/T-303。
- **接受** O1 精化:switch 紧邻前可注入的最后一次 `IsRunning` 复查;**不**把 rename 失败一概映射为 `app_running`;与启动协议一并在 T-401 落成,列 T-303 为接口前提;Windows 映像节/文件锁语义留 T-601 真机验证。
- **接受** O2 升 P1:区分 ZIP 读/解压失败与 staging 写失败、保留原始 error chain(`%w`)、可识别"磁盘满"归一 `disk_full`;分类判定经接口/平台适配注入,不在 core 直接依赖 Windows API。
- **接受** O3 精化(定义安全消费协议:仅成功后由下载队列/编排层按 request_id 派生路径删除,失败保留;不让 InstallService 删任意外部路径)、O4 降级(verify=可信性验证 / preflight=环境 hook,语义可区分,不改稳定 stage API)、O5 维持低优先。
### 新增(对 O2 的补强):磁盘满整改需覆盖 write→sync→close 全序列
O2 的修复**不能只改 `io.Copy` 那一行**。ENOSPC 在 Linux 常因延迟分配推迟到 `write`/`fsync`/`close` 任一处暴露,当前三处给出 `zip_corrupt`/`install_failed`/`install_failed` 三种码。整改需:
- 用平台感知 ENOSPC 判定(POSIX `syscall.ENOSPC` / Windows `ERROR_DISK_FULL`,经注入接口,不进 core)在 write/sync/close 三处统一归一 `disk_full`。
- 把 `output` 包一层"记录写错误的 writer"以区分写失败 vs 读/解压失败(`io.Copy` 本身不告知哪侧失败)。
- 明确清理失败(`RemoveAll` 报错)的恢复语义。
### 最终处理顺序(定稿)
1. **[可靠性 · P1]** O1:switch 紧邻前显式复查 `IsRunning`(收窄 TOCTOU),随 T-401 启动协议同契约落成,列 T-303 为前提;真机文件锁验证归 T-601。
2. **[错误可诊断性 · P1]** O2:write→sync→close 全序列统一 ENOSPC→`disk_full`,保留 error chain,区分读/写失败,分类经平台适配注入;明确清理失败恢复语义。
3. **[生命周期]** O3:正式安装编排定义 completed 下载文件的安全消费与失败保留协议。
4. **[低优先]** O4/O5:stage 命名维持现状;预留模型随 backup 策略复核。
5. **[文档]** 修正 `current-state.md` / 相关文档中 M3 表述:无头 core 安装边界已完成,端到端编排与启动闭环待 T-401。
> 裁定:本轮 Codex 复核成立且优于原审核——撤回 M3 闭环 overstate、纠正 O2 定性(位置+严重性)、收回 O1 有缺陷建议、限定"无安全缺陷"范围。安全边界总结论(T-302/T-303 无绕过)双方一致,不需回滚;新增"磁盘满三落点"补强并入处理顺序第 2 步。
@@ -0,0 +1,53 @@
# 整改审核:T-606 ~ T-614(Phase 1/2 审查结论的落地验证)
> 审查范围:`f1cc730`(T-606)、`0945fe9`(T-607)、`1b7f72e`(T-608)、`1715729`(T-609)、`e9386d2`(T-610)、`320b83d`(T-611)、`0f69fa3`(T-612)、`20596a7`(T-613)、`0c1b766`(T-614)。
> 审查视角:全栈开发工程师。
> 审查方式:静态代码审计——**不信任任务自述,逐项核实际代码**;重点核并发、耐久性、路径/DoS、签名四类高风险改动。因 WSL 无 Go,未执行测试。
> 日期:2026-07-18
## 总体判断
**合理,且是真修复,不是文档层的自述。** Codex 系统性关闭了 Phase 1 与 Phase 2 审查的**全部可执行结论**——不仅 Phase 2 的 6 项(T-606~611),还回补了 Phase 1 遗留的三项(T-612 中央目录 DoS、T-613 断电耐久性、T-614 签名跨实现向量)。逐项核了实际代码,实现方向和落地都对。剩余的是几处诚实的残留(见末尾),不影响"整改成立"的结论。
## 逐项核实(代码级)
| 任务 | 对应审查项 | 核实结论 | 代码证据 |
| --- | --- | --- | --- |
| T-606 | Phase2 P1 + 交叉裁定"内存无界" + Codex NEW P1 | **成立** | `icon_cache.go`:in-flight 去重(leader 在锁外做 disk/network,waiter 释放锁等 `flight.done` 或 `ctx.Done()`,`delete(inflight)`+`close(done)` 在成功/失败都执行,无泄漏);`icon_memory.go` 用 `container/list` LRU 按字节(32MiB)+ 条数(256)双限;fetcher 改流式 `IconFetchResponse{Body,ContentLength}`,`readFetchedIcon` 先拒 `ContentLength>max`、再 `io.ReadAll(io.LimitReader(Body,max+1))`、再复检、`defer Body.Close()`;`SetItems` 剪枝 `shell.icons` 等全套图标 map |
| T-607 | Codex NEW P2(UI 线程投递) | **成立** | 后台 Load/Decode 只发事件,经有界 FIFO relay 回 Gio UI goroutine 后再 `ApplyEvent`/`ApplyIcon`;`SetItems` 剪枝 `iconRequests/iconApplied/iconFailures`,迟到结果按最新请求/删除/IconRef 变化/取消阻断 |
| T-608 | Phase2 P2(sharpen) | **成立** | 双端交互契约测试:验证 Editor/Clickable 经 Layout 更新共享 model、重排后 AppID 行身份、详情关闭上下文、500 项 viewport、controls 释放;非重复断言 ViewModel |
| T-609 | Phase2 P4(sharpen) | **成立** | `refilter` 构造新 backing array 后原子替换(非每帧拷贝),旧 generation 跨五类 mutation 稳定 |
| T-610 | Phase2 P5 | **成立** | 双端 `shell.go` 拆为 header/navigation、catalog/list、detail、style 同 package 职责文件,根调用链不变 |
| T-611 | Phase2 P6 | **成立** | symlink/非普通 cache entry fail-closed、零 fetch、不改写 entry/target,发 `IconFailed/unsafe_cache` + 人工 runbook;自动 quarantine 仍留威胁模型裁定 |
| T-612 | Phase1 P2(中央目录 DoS) | **成立** | 新增 `zipscan.go`;`ExtractFile` 要求 `expectedPackageSize`,`openAndScanArchive` 在 `zip.NewReader` **前**校验文件大小精确等于 Catalog 声明并预扫描;`limits.go` 加 `MaxArchiveBytes`(4GiB)+ `MaxCentralDirectoryBytes`(且校验 ≤ archive) |
| T-613 | Phase1 P1(断电耐久性)+ Codex NEW(payload 未 sync) | **成立** | `durability.go` 抽象 fence;**`durability_windows.go` 用 `syscall.FlushFileBuffers`、`durability_other.go` 用 `directory.Sync()`**;`extractor.go` 每个 payload `syncFileWithFence` + `syncStagingTree`;`switcher/transaction/recovery/layout` 在每次 rename、journal 替换、备份/恢复/删除后插目录 fsync 点 |
| T-614 | Phase1 P3(跨实现签名向量) | **成立** | `canonical.go` 加 `validateJSONStringSurrogates`(拒绝孤立高/低 surrogate、不配对);`testdata/catalog/canonical-vectors.json` 覆盖 reject-isolated-high/low-surrogate、mismatched-pair、negative-zero、signature-crlf/space/tab、missing/extra-padding、equivalent-object-order |
## 重点模块判断
- **T-606 并发正确性**:in-flight 去重无死锁(leader 全程不持锁做 IO,waiter 只等 channel),不同 key 真并行、同 key 只拉一次、错误也清 flight。这是对我"naive check-unlock-fetch"的正确超越。
- **T-613 耐久性方向正确**:目录级 fsync 的平台分叉(Windows `FlushFileBuffers` / POSIX dir `Sync`)+ payload 文件 sync + journal 与 rename 之间的顺序点,正是 Phase 1 P1 建议的完整落地。fence 可注入,`durability_test.go`(334 行)做步骤间故障注入。
- **T-612/T-614 安全闭合**:包大小在解析任何 ZIP 数据前强制等于已验签 Catalog 声明;签名向量把 surrogate/base64 空白/负零/等价顺序行为**冻结**成跨实现测试,发布端可据此对齐。
## 残留与提醒(不影响"整改成立",但应记录)
### R1 · T-613 的耐久性是"构造正确",真机断电验证仍未做(且 Windows 更需要)
fsync 点插得对,但**真实断电保证依赖 OS/FS 履行 fsync 语义**。关键:Windows 对**目录句柄** `FlushFileBuffers` 是否真正持久化目录项,MSDN 不像 POSIX `fsync(dir)` 那样有明确保证。因此:
- 结论应表述为"耐久性顺序已构造正确",而非"断电安全已验证"。
- 真机/VM 断电故障注入(Phase 1 已划 T-302/T-601)在 **Windows 上比 POSIX 更必要**,不能因为代码已插 fsync 就跳过。
### R2 · T-613 逐 payload 文件 fsync 的性能成本待真实包复核
`syncFileWithFence` 逐个 payload 文件调用;含数千文件的包会产生数千次 fsync,在慢盘或杀软介入下安装可能明显变慢。正确性优先的取舍成立,但阈值/策略应在 T-302 用真实包分布复核(可评估"逐文件 sync + 目录 sync"是否可优化为"批量 + staging 树 sync")。
### R3 · T-606 waiter 继承 leader 的 ctx 取消(singleflight 通病,低危)
leader 的 ctx 取消会让所有 waiter(即使自身 ctx 有效)拿到取消错误。对图标是良性(重试即可),但若未来复用该模式于非幂等/高代价资源,需重新评估是否给 waiter 独立重试或 leader 交接。
## 结论
T-606~T-614 是一次**系统性、可核实**的整改:Phase 1 与 Phase 2 审查的可执行结论已全部落地并接线,实现质量与前几个阶段一致。当前无需回滚或返工。剩余的是三条已记录的残留(R1 真机断电验证、R2 fsync 性能、R3 singleflight 通病),其中 R1 是唯一需要在发布前用真实环境关闭的项,且本就属 T-302/T-601 既定范围。
> 声明:本报告为静态代码审计,逐项核实了实现而非任务自述;因 WSL 无 Go 未执行测试,Codex 自述双 workspace 闸门通过。R1 的真机断电语义本就需硬件级注入,不由静态审计或步骤级故障注入替代。
+18 -2
View File
@@ -12,7 +12,7 @@
│ 视图 │ 每项:图标·名称·版本·状态·进度·主操作按钮 │
│ 全部 │ │
│ 已安装 │ 右侧/弹层:软件详情 │
│ 可更新 │ 版本 · 简介 · 教程 · 授权状态 · 操作 │
│ 可更新 │ 版本 · 简介 · 安全诊断 · 状态 · 操作 │
│ 最近使用│ │
├────────┴────────────────────────────────────┤
│ 底部状态栏:网络 · 任务数 · 磁盘 · 盒子版本(Legacy 标识) │
@@ -24,7 +24,7 @@
| 视图 | 职责 | MVP |
| --- | --- | --- |
| 软件列表(主视图) | T-203 已实现全部/已安装/可更新切换、名称/ID/tag 即时搜索、单分类筛选、稳定滚动与明确空状态;下载中/最近使用和多标签复选随对应用例后续接入 | P0 |
| 软件详情(弹层或右栏) | T-204 已实现右栏、关闭、版本/分类/简介/tags/状态/不可用原因/教程/主页展示与内存图标;真实操作和授权事实随对应模块接入 | P0 |
| 软件详情(弹层或右栏) | T-204 已实现右栏、关闭、版本/分类/简介/tags/状态/不可用原因/教程/主页展示与内存图标;T-611 仅对当前 `unsafe_cache` 显示稳定安全诊断和人工恢复提示;真实操作和授权事实随对应模块接入 | P0 |
| 下载队列 | 所有任务的进度、速度、剩余时间;暂停/取消/重试 | P0 |
| 设置 | 并发数、目录、代理、自动检查更新、beta 通道、日志级别、便携模式(V2) | P0(最小集) |
| 授权 | 许可证导入、已授权软件列表、machine 信息、换绑/申诉入口 | P0 |
@@ -43,6 +43,18 @@
| LicensePanel | 授权 | 导入与状态展示 |
| Toast/Dialog | 全局 | 错误与确认交互 |
T-610 固定 modern 与 Win7 两个 `ui/gio` 适配器的同 package 文件职责;文件名镜像,但不跨 workspace 共享 Gio 代码:
| 文件 | 职责 |
| --- | --- |
| `shell.go` | `AppShell` 状态/构造、`SetItems`/`ApplyIcon` 生命周期、根 `Layout` 与输入 drain |
| `shell_header.go` | 顶部栏、分类、view/filter 导航与版本特有 footer |
| `shell_catalog.go` | content/catalog、惰性列表、app row/icon 与空状态 |
| `shell_detail.go` | 详情布局、详情字段、`unsafe_cache` 安全诊断与动作/回退文案 helper |
| `shell_style.go` | palette/theme、panel 绘制、view/status 文案与颜色 helper |
这些文件仍共同实现一个 `gio` package,不是新增页面 API 或状态层。modern-only 的 `layoutCatalog`/`layoutFooter`/`actionLabel` 等保留在对应职责文件,Win7 不增加空壳;后续视图应进入相应职责文件,不能重新把布局链堆回根 `shell.go`。
每个页面用独立 struct 保存:`widget.Clickable`、`widget.Editor`、`layout.List`、过滤条件、当前 ViewModel、pending RequestID、临时提示和错误状态。
## 交互规则(硬约束)
@@ -63,12 +75,16 @@ T-203 已落地的列表交互约束:
T-608 用 modern/win7 同场景适配器契约固定上述接线:Editor 和视图/分类/行/恢复/关闭 Clickable 必须经 `Layout`/`drainInput` 更新共享 model;Catalog 重排后行事件仍按 AppID 选择,关闭详情保留筛选与 list First/Offset。500 项有限 viewport 同时核对实际布局计数与 `layout.List.Position.Count`;空 Catalog、过滤无结果及恢复按钮通过 Gio 语义树区分。两端测试只在 edition/窗口尺寸上保留真实差异,不互相 import Gio。
T-609 固定 `VisibleItems()` 的只读 snapshot generation:筛选或 Catalog 变化时由 model 完整构造新 backing array 后发布,已被上一帧持有的旧 generation 保持稳定;同一 generation 的每帧读取不复制。两个 Gio 适配仍只在 UI owner goroutine 读取,不得修改返回 slice、item 或 Tags,该契约不提供并发读写安全。
T-204 已落地的详情/图标约束:
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
- modern 与 Legacy 均显示版本、分类、简介、tags、状态、不可用原因、教程和主页文本;尚未接入的安装/启动/授权不伪装为已可执行操作。
- T-607 由 `IconEventDelivery` 在后台完成可信加载/解码并发布强类型 `IconReady`/`IconFailed`;有界 FIFO relay 只请求 Invalidate,Frame/UI goroutine drain 后才调用 `ApplyEvent`/`ApplyIcon`。相同 app 只接受最新且 IconRef/DPI 匹配的 request_id;Catalog 删除、IconRef 变化或取消后丢弃迟到结果,IconRef 变化同时清除旧 ImageOp。
- 图标未命中或离线缓存不可用时显示非 emoji 的字母占位,不阻塞列表或详情。
- T-611 将最近一次已验证失败保存为完整 `IconEventIdentity + IconFailureCode`;新请求、匹配 ready、IconRef/DPI 变化、取消和 app 删除清除旧诊断,同引用 Catalog snapshot 可保留。只有 `unsafe_cache` 在当前选中详情显示文字告警、`unsafe_cache`、app ID 与 `<digest>-<dpi>.icon` locator;`unavailable`/`invalid_content` 保持普通占位,不冒充安全事件。
- 安全告警必须有可访问标题、明确说明本次未继续远端获取/自动修复并指向 [故障排查](troubleshooting.md);颜色不能作为唯一信号,不得增加清理、隔离或重试按钮,不得显示绝对路径、原始 error、URL/query 或 link target。
## 导航规则
+67
View File
@@ -0,0 +1,67 @@
---
id: T-302
title: 安装流程整合
phase: 3
deps: [T-301, T-102, T-103]
status: DONE
created: 2026-07-18
issue: null
context_ref: 6575c9ad9b997366514277a925196b2e52f16f75
claim_branch: null
work_branch: agent/codex/T-302
write_paths:
- docs/tasks/T-302.md
- core/application/
- core/installer/
- core/storage/
- docs/api.md
- docs/04-architecture.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
T-301 只能把网络字节可靠地落为隔离的 `.download`;下载任务元数据、完成路径和文件内容仍可被本地篡改,不能直接作为安装身份或可信包。T-102/T-612/T-613 已分别具备 ZIP 预扫描/安全解压和可恢复的 `staging → current → backup` 切换原型,但尚未把已验签 Catalog、完整包 SHA-256、严格 `app.json` 身份比对、安装记录和健康失败回滚串成一个可调用的 core use case。
本任务关闭这条生产安装链的代码级信任边界。外层 Catalog 的 Ed25519 签名覆盖嵌套 `packages[arch]` 的 `size`、`sha256` 与 `signature` 字段;当前协议没有定义 package `signature` 的独立待签名字节、密钥或轮换域,T-302 不得臆造第二套包级验签。它只接受已经由 `catalog.Client` 验签、严格解析并按目标过滤后的 Catalog 选择,并以该选择中的精确 size/SHA-256 重新验证本地完成文件。
## 方案
1. 在 `core/application/install` 落地 `InstallService`:请求包含已过滤的 `catalog.Entry`、目标 architecture 和 `.download` 路径。服务必须拒绝不可安装 entry、缺失 package、architecture 与 `App.Packages[architecture]` 不一致的选择;不得从 `downloader.Task`、`DownloadCompletedPayload` 或本地 metadata 重新取得 app/version/size/hash。该 application 子包独立于被 Catalog 图标投递依赖的父包,避免反向 import cycle。
2. 在 `core/installer` 增加经过验证的包提取入口。它以一次 `Lstat → open → fstat → Lstat` 得到普通文件句柄,先确认实际长度精确等于 Catalog size、再以**同一文件句柄**计算 SHA-256,并以常量时间比较 Catalog hash;哈希不符时不得构造 `zip.Reader`、读取 `app.json` 或创建 staging。随后仍以同一句柄完成 EOCD/ZIP64/中央目录预扫描和 `zip.Reader` 构造,不能按路径重新打开。
3. 安全读取 ZIP 根目录唯一的 `app.json`(最大 1 MiB,严格 JSON、无未知字段/尾随值),校验 v1 的全部字段与共享 Windows 安全相对路径规则。身份字段必须与 Catalog 选择一致:`id`、`version`、`channel`、`min_os`、`architecture`、`entrypoint`、`requires_admin`;其余 v1 常量和值也必须符合 `schemas/app.schema.json`。只有通过比对后,才复用既有两阶段 ZIP 检查把 `payload/` 解压到全新的 `apps/<id>/staging`。
4. 解压复制时为每个实际 payload 文件计算 size/SHA-256,作为 `ExtractResult` 的已观察文件清单。`InstallService` 将此清单写入 `installed-app.json`,不信任或依赖可选的 `files.json` 作为新的信任根;保留 v1 `files.json` 的协议语义和后续修复功能边界。
5. 每次安装先对 app root 调用既有 `installer.Recover`。提取成功后以已有 `Switcher` 激活 staging;必需的注入 health check 先通过,随后在同一个 switch health 阶段原子写入新 `installed-app.json`,使 health/记录写失败走既有 rollback,保留旧 `current` 与旧记录。成功才清理 backup/journal。返回的阶段化错误和结果要能让后台调用方区分验证、解压、切换/回滚和记录失败;UI 仍只通过 application 事件/结果更新状态,不在 Gio Layout 做 IO。
6. 为上列顺序补齐隔离的单元/集成测试:成功安装、Catalog 选择不一致、非普通/替换的完成文件、size/SHA 不符(确认 ZIP/app.json/staging 未触及)、恶意或不匹配 `app.json`、完整安装记录文件清单、已有版本健康或记录写入失败后的回滚、安装前 transaction recovery。测试包只使用运行时生成的虚构 ZIP/哈希。
## 验收要点
- `InstallService` 的唯一可信输入是已验签/过滤 Catalog 的 entry + architecture;篡改下载 metadata、request/app ID 或 completed 路径不能改变实际验证的 package identity、size 或 SHA-256。
- 普通完成文件的 Catalog size 和 SHA-256 均在同一打开句柄上通过后,才允许 ZIP 预扫描、`app.json` 读取、staging 创建和 payload 写入;所有失败保留 `errors.Is` 可识别的根因。
- `app.json` 严格符合 v1,并与 Catalog 的 ID、版本、通道、最低系统、架构、入口和管理员标记一致;安全路径、未知字段、重复/缺失入口或不匹配一律拒绝。
- 成功链为 `recover → verify → manifest compare → safe staging extract → switch → health → installed-app record → committed cleanup`;记录含每个实际 payload 文件的安全相对路径、字节数和 SHA-256。
- health 或记录写失败时,已有版本的 `current` 与旧 `installed-app.json` 恢复并保持;首次安装不留下可执行 `current`。已有未完成 transaction 先按恢复状态机收敛。
- `go -C core vet ./...`、`go -C core test -count=1 ./...`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py` 与 `python scripts/validate_harness_governance.py` 全部通过;任务执行记录写明结果。
## 边界(不改什么)
- 不修改 Catalog 签名协议、Schema 或密钥;不为 nested package `signature` 虚构独立验签算法。外层已验签 Catalog 是本任务唯一的密码学身份来源。
- 不做磁盘空间预检、程序占用/退出等待、面向 UI 的完整错误码映射或下载重试策略(T-303/T-401);不强杀或自动启动包内程序。
- 不做 Gio 安装面板、下载完成到安装调用的 UI 编排或物理断电/杀毒软件/文件锁故障注入。T-302 只提供无头 core use case;真实 Windows VM/真机故障注入转入 T-601 发布前环境验证。
- 不改变 `files.json` 的 v1/v1.1 协议或实现修复功能,不引入数据库、第三方包、Gio、Windows API 或 Go 1.21+ API。
## 协作约束
- 当前项目为单 Agent 串行模式;当前 Agent 独占全部 `write_paths`,自行完成设计、安全复核、测试和提交,不启动子 Agent。
- 先提交本任务规格与关联协议/架构/状态文档;领取后将本文件改为 `DOING`,填写当前 HEAD `context_ref` 与 `work_branch: agent/codex/T-302`,再运行基线和实现。
- T-303、T-401 及任何后置任务在本任务 `DONE`、验证与实现提交前不得领取或提前修改。
## 执行记录
- 2026-07-18:正式落成。冻结可信输入、同句柄 size/SHA/ZIP 顺序、严格 app.json 对齐、实际 payload 文件记录、health 内记录写入回滚语义和后续任务边界。
- 2026-07-18:领取任务,基线为 `6575c9ad9b997366514277a925196b2e52f16f75`,工作分支 `agent/codex/T-302`;下一步运行统一初始化/完整基线,再开始实现。
- 2026-07-18:基线通过:`./init.ps1` 完成治理、core 架构/Go 版本闸门、Go 1.20 core vet/test、modern/Win7 test/build 与 Python harness 校验。
- 2026-07-18:实现 `core/application/install.InstallService`、同句柄 `Extractor.ExtractVerifiedFile`、严格且有 1 MiB 上限的 app.json 校验、payload 观测 hash 清单和 `InstalledAppStore.EnsureAppRoot`;Catalog 依赖父 application 包的既有图标投递链会产生 import cycle,因此 use case 放在独立 application 子包,未改变 Gio/UI 边界。
- 2026-07-18:复核成功/size+hash+选择失败/严格 manifest/非普通文件/manifest 上限/transaction recovery/health 与记录写失败回滚;`go -C core vet ./...`、`go -C core test -count=1 ./...` 和 `go -C core test -count=10 ./installer ./application/install` 全部通过。真实 Windows 断电、文件锁/杀毒软件故障注入未在当前环境执行,保留为 T-601 发布前验证。
+62
View File
@@ -0,0 +1,62 @@
---
id: T-303
title: 失败处理与磁盘预检查
phase: 3
deps: [T-302]
status: DONE
created: 2026-07-18
issue: null
context_ref: 449b1832741bef57fd6ef9bc771d2702a9120dc5
claim_branch: null
work_branch: agent/codex/T-303
write_paths:
- docs/tasks/T-303.md
- core/application/install/
- core/installer/
- docs/api.md
- docs/04-architecture.md
- docs/00-ai-start-here.md
- docs/current-state.md
---
## 问题 / 背景
T-302 已把已验签 Catalog selection、同句柄 package 校验、严格 `app.json`、安全 staging 和可回滚 switch 串成无头安装 use case,但失败结果仍主要是内部错误链。调用方尚不能稳定地区分哈希不符、包损坏、可用磁盘不足和已有版本正在运行,也没有在创建 staging 前依据实际解压计划阻止空间不足的写入。
更新场景中,任何上述失败都必须保留旧 `current` 与其 `installed-app.json`;不能以错误码映射为由提前切换、删除旧版本,或把原始底层错误直接作为 UI 合约。运行状态的 Windows Toolhelp 实现和等待用户正常退出属于 T-401,本任务只建立 core 可注入的预检边界。
## 方案
1. 在 `core/installer` 为已经通过同句柄 size/SHA、ZIP 预扫描和 `app.json` 身份比对的包暴露只读的 verified-package pre-extract hook。hook 提供已规划 payload 的精确展开字节数、普通文件数和已验证 entrypoint;它在任何 staging 创建或 payload 写入之前调用。保留现有无 hook 提取入口,避免把 application/平台依赖带入 installer。
2. 将 `InstallService` 的构造改为显式配置并强制注入 `DiskSpaceChecker` 与 `TargetStateChecker`。pre-extract hook 对 app root 查询可用空间,并要求至少容纳 payload 展开字节数加固定 64 MiB staging/元数据保留;容量查询失败、非法返回值或可用空间不足一律 fail closed。运行检查只接受已验证的 app ID 与 `current/<entrypoint>` 路径;发现运行、或状态无法可靠取得时一律在 staging 前终止。本任务不启动、终止、等待或枚举进程,也不在 core import Windows API。
3. 在 `core/application/install` 定义稳定 `FailureCode` 与带 stage/code/root cause 的安装错误。至少冻结 `hash_mismatch`、`zip_path_escape`、`zip_corrupt`、`disk_full`、`disk_check_failed`、`app_running`、`target_state_unavailable`、`package_invalid` 与兜底 `install_failed`;底层根因继续可由 `errors.Is` 识别,UI/事件只消费稳定 code。把已存在的 package/switch/health/record 错误按此表映射,不能暴露文件路径或原始系统错误作为用户合约。
4. 为成功、精确空间阈值、空间不足、容量查询失败、运行中、运行状态查询失败、哈希不符与 ZIP/CRC 损坏补齐单元/集成测试。每个失败都断言 staging 未创建或被清理、旧 current/安装记录保持可用、错误码确定且根因仍可识别;验证通过才更新任务状态。
## 验收要点
- `installer` 在所有 Catalog/ZIP/manifest 验证成功后、创建 staging 前,向调用方给出精确 payload bytes/files 与安全 entrypoint;hook 失败不产生 staging/payload 写入。
- `InstallService` 无法在缺少 disk 或 target-state checker 时构造。它以 `payload_bytes + 64 MiB` 为所需空间;可用空间小于该值返回 `disk_full`,等于该值允许继续,查询故障返回 `disk_check_failed`。
- 对已有版本更新时,`hash_mismatch`、`zip_path_escape`、`zip_corrupt`、`disk_full`、`app_running` 和两种 checker 故障均发生在 switch 前,旧 `current` 和旧 `installed-app.json` 字节保持不变;首次安装不留下 executable `current`。
- 所有安装失败以稳定英文 `FailureCode` 报告,原始原因仍可被 `errors.Is` 检测;错误码表已写入 `docs/api.md`,不包含绝对路径、URL、token 或系统原始错误。
- `go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./installer ./application/install`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py` 与 `python scripts/validate_harness_governance.py` 全部通过;任务执行记录写明结果。
## 边界(不改什么)
- 不修改 Catalog、`app.json`、`files.json`、installed-app Schema 或签名/哈希协议;不改变 T-302 同句柄验证和 switch/rollback 的先后顺序。
- 不实现 Toolhelp 进程枚举、等待退出、强杀、启动程序、Gio 状态展示、下载重试或命令层依赖装配;T-401 负责 Windows 运行检测与启动协议,后续 UI/编排任务负责事件展示和实际装配。
- 不以磁盘预检查替代写入时的错误处理;预检查与 extract/switch 的所有 I/O 错误仍必须 fail closed 并保留 rollback 语义。
- 不引入第三方包、Gio、Windows API、SQLite 或 Go 1.21+ API;不做物理断电、文件锁或杀毒软件故障注入(T-601)。
## 协作约束
- 当前项目为单 Agent 串行模式;当前 Agent 独占全部 `write_paths`,自行完成设计、安全复核、测试和提交,不启动子 Agent。
- 先提交本任务规格与关联协议/架构/状态文档;领取后将本文件改为 `DOING`,填写当前 HEAD `context_ref` 与 `work_branch: agent/codex/T-303`,再运行基线和实现。
- T-401 及任何后置任务在本任务 `DONE`、验证与实现提交前不得领取或提前修改。
## 执行记录
- 2026-07-18:正式落成。冻结 verified-package hook、64 MiB 解压保留、强制注入 disk/target-state checker、稳定失败码及 T-401 的进程检测边界。
- 2026-07-18:领取任务,基线为 `449b1832741bef57fd6ef9bc771d2702a9120dc5`,工作分支 `agent/codex/T-303`;下一步运行统一初始化/完整基线,再开始实现。
- 2026-07-18:基线通过:`./init.ps1` 完成治理、core 架构/Go 版本闸门、Go 1.20 core vet/test、modern/Win7 test/build 与 Python harness 校验。
- 2026-07-18:实现 verified-package pre-extract hook(仅暴露已验证 payload bytes/files/entrypoint)和 `InstallServiceConfig` 的强制 disk/target-state 注入;预检在严格 ZIP/app manifest 验证后、staging 创建前执行,空间阈值固定为 payload 加 64 MiB。稳定 `FailureCode` 保留原始 `errors.Is` 原因,未引入 Windows API 或命令层装配。
- 2026-07-18:复核成功、精确容量阈值、首次/更新磁盘不足、磁盘/运行状态查询故障、运行中、哈希不符、CRC 损坏、hook 顺序及旧版本/记录保护;`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./installer ./application/install`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py` 与 `python scripts/validate_harness_governance.py` 全部通过。Windows Toolhelp/正常退出等待和命令层实际装配仍保留给 T-401。
+89
View File
@@ -0,0 +1,89 @@
---
id: T-609
title: 修正 VisibleItems 快照生命周期
phase: 2
deps: [T-608]
status: DONE
created: 2026-07-17
issue: null
context_ref: ed9ded21109ec36373ed0f13fa42d1ebf55a5bbf
claim_branch: null
work_branch: agent/codex/T-609
write_paths:
- docs/tasks/T-609.md
- core/application/
- docs/routes.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/review/phase2-review.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
`core/application.CatalogListModel.VisibleItems()` 当前直接返回 `model.visible`,`refilter()` 则从 `model.visible[:0]` 开始 append。Gio Layout 每帧只读当前切片时功能正常,但任何调用者只要跨一次 `SetQuery`、`SetCategory`、`SetView`、`ResetFilters` 或 `SetItems` 保留旧切片,旧切片的 backing array 就可能被新结果覆盖。导出的“immutable-by-convention snapshot”因此没有稳定生命周期,调用方也容易误以为旧快照可以安全保留到下一帧。
直接让 `VisibleItems()` 每次返回完整副本可以隐藏该问题,但 modern/win7 Layout 每帧都会调用它;对数百项列表逐帧复制会引入持续分配和复制成本,与虚拟列表性能目标冲突。Phase 2 审核定稿已选择在筛选状态实际变化时构造新 backing array、完成后替换当前快照,让读取继续保持 O(1)。
本任务只收紧共享 ViewModel 的快照代际语义:旧快照在后续 model 更新后内容保持不变,当前快照仍由调用方按只读约定使用。它不把 `CatalogListModel` 改成跨 goroutine 并发容器,也不为恶意调用方提供逐字段防御性复制。
## 方案
1. 修改 `core/application.CatalogListModel.refilter` 的快照构造方式:
- 先在局部新切片中按现有 category/view/query 规则构造完整结果,不得从 `model.visible[:0]` 复用上一代 backing array。
- 构造完成后再以一次普通字段赋值替换 `model.visible`;“原子替换”描述的是单 owner goroutine 内不暴露半成品,不引入 `sync/atomic`、mutex 或并发读写承诺。
- 保持现有筛选顺序、稳定项顺序、空结果语义和 `CatalogListItem` 值不变。
2. 保持 `VisibleItems()` 为无复制读取:
- 不在每次调用时 clone 当前切片,不新增逐帧 O(n) 分配。
- 更新注释明确返回值属于当前 snapshot generation,调用方可在后续 model 变更后继续读取旧 generation,但不得修改切片元素、嵌套 `Tags` 或容量范围。
- 同一 generation 内重复调用应观察同一 backing array;只有实际执行 refilter 才发布新 generation。setter 因值未变化而提前返回时不强制创建无意义快照。
3. 在 `core/application/catalog_list_test.go` 增加生命周期回归矩阵:
- 分别跨 `SetQuery`、`SetCategory`、`SetView`、`ResetFilters` 和 `SetItems` 保留旧快照,验证旧快照的长度、ID、字段及 tags 不被新结果覆盖。
- 对非空结果验证 refilter 前后 backing array 不同;同一 generation 的重复 `VisibleItems()` 不复制且仍指向同一 backing array。
- 覆盖空 Catalog、过滤为零项、恢复全部和 stable order,确保修复不改变现有 ViewModel 语义。
- 测试只通过公开 model 操作触发代际变化,不直接调用 `refilter` 或依赖未导出容量细节。
4. 保持两个 Gio 适配器调用方式不变,通过双 workspace 测试/构建证明 modern 与 Win7 每帧读取 `VisibleItems()` 不需要迁移到新 API。
5. 同步架构、路由和编码规则,冻结“变更时分配、读取时只读 O(1)、UI owner goroutine”契约。
## 验收要点
- `refilter` 不再通过 `model.visible[:0]` 复用上一代 backing array;新结果完整构造后才替换当前快照。
- 调用者在任一公开 refilter 入口前保存的非空旧快照,在后续 query/category/view/reset/items 变化后长度、顺序、字段和 tags 保持原值。
- 非空新 generation 与仍被持有的旧 generation 不共享外层 backing array;同一 generation 内重复调用 `VisibleItems()` 共享当前只读快照且不逐次复制。
- `VisibleItems()` 文档明确只读约定和生命周期,不声称线程安全;调用方修改返回切片或嵌套数据仍属于违反契约。
- 现有搜索、分类、all/installed/updates、selection、空状态和 stable order 测试保持通过,不改变 UI 可见业务语义。
- 不增加 `VisibleLen`/`VisibleItem` 第二套 API,不修改 modern/win7 shell 调用代码,不引入锁、`sync/atomic` 或新依赖。
- Go 1.20.14 + `GOWORK=off` 下 `go vet ./application`、`go test -count=10 ./application` 通过。
- modern Go 1.25.0 与 win7 Go 1.20.14 的 UI 测试和 Windows amd64 构建通过;`./scripts/verify_phase0.ps1` 全绿。
- `python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。
## 边界(不改什么)
- 不把 `CatalogListModel` 改成线程安全对象;所有 setter、selection 与 snapshot 发布继续由同一 application/UI owner goroutine 串行调用。
- 不在 `VisibleItems()` 每次读取时复制,不深拷贝每个 item/tag,不承诺防御违反只读约定的调用方写入。
- 不增加 `VisibleLen()`/`VisibleItem(index)`、iterator、泛型集合或其他并行列表 API,不修改公开业务字段和筛选规则。
- 不修改 modern/win7 的 `shell.go`、Gio 依赖或 T-608 适配器契约;双端只作为不迁移调用方的回归验证。
- 不拆分 `shell.go`,该维护项按审核顺序另立后续任务。
- 不处理 `ErrIconCacheUnsafe` quarantine/诊断策略、ZIP 中央目录预扫描、断电耐久、Catalog 签名向量或 T-302 安装整合。
- 不修改协议/Schema,不升级 Go/Gio,不合并 workspace,不修改、提交或删除用户的 `soft_quay.code-workspace`。
## 协作约束
- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。
- 本任务只允许修改 frontmatter 中的 `write_paths`;若修复需要改变 Gio 调用方式、公共协议或线程模型,必须停止并记录,不得在 T-609 内扩大边界。
- T-609 完成、完整验证并提交前,不落成或领取 Phase 2 审核顺序中的后续任务。
## 执行记录
- 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第四优先级整改落成任务;现有全局最大任务为 T-608,因此取 T-609,依赖已完成的 T-608。
- 2026-07-17:代码图确认 `VisibleItems` 有 core 测试与 modern/win7 Layout/适配器契约等 8 个调用点,当前为直接返回;五个公开 mutation 入口最终调用 `refilter`,而 `refilter` 明确以 `model.visible[:0]` 重用上一代 backing array。
- 2026-07-17:任务采用审核定稿方案 1:状态变化时构造并替换新外层快照,每帧读取不复制;只读约定覆盖 slice 元素与嵌套 tags,但不把本任务扩展为并发安全或防御性深拷贝。
- 2026-07-17:在 `agent/codex/T-609` 分支领取任务,基线为 `ed9ded21109ec36373ed0f13fa42d1ebf55a5bbf`;保持单 Agent 串行执行。
- 2026-07-17:基线 `./init.ps1` 通过,包含治理/上下文/边界/依赖版本检查、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的测试和 Windows amd64 构建。
- 2026-07-17:先增加生命周期回归矩阵并在旧实现运行;query/category/view/reset/items 五条公开 mutation 路径均稳定复现旧 generation 被覆盖,实际状态变化后 backing array 地址也未变化,证明测试能捕获审核指出的问题。
- 2026-07-17:`refilter` 改为在局部 `visible` 中完整构造新结果后一次赋给 `model.visible`,不再使用 `model.visible[:0]`;`VisibleItems` 仍直接 O(1) 返回当前 generation,注释明确旧 generation 稳定、slice/Tags 只读、model 单 owner 且不支持并发读写。
- 2026-07-17:测试覆盖旧快照跨 SetQuery/SetCategory/SetView/ResetFilters/SetItems 后字段与 Tags 不变、非空 generation backing 分离、同 generation 重复读取共享 backing 且 `AllocsPerRun=0`、no-op setter 不换 generation、空 Catalog/无匹配/恢复顺序。Go 1.20.14 `go vet ./application` 与 `go test -count=10 ./application` 通过。
- 2026-07-17:modern Go 1.25.0 与 win7 Go 1.20.14 的 `go test -count=5 ./ui/gio` 分别通过,证明两个 Gio 调用方继续使用原 `VisibleItems()` API,无需修改 shell 或适配器契约。
- 2026-07-17:完整 `./scripts/verify_phase0.ps1` 通过,包含治理/上下文/边界/版本校验、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的 UI/平台测试及 Windows amd64 构建;架构、路由、编码规则、审核追踪和当前状态已同步。
+89
View File
@@ -0,0 +1,89 @@
---
id: T-610
title: 拆分双端 Gio shell 职责
phase: 2
deps: [T-609]
status: DONE
created: 2026-07-17
issue: null
context_ref: 75b1803564dd95284dc5bc25719ccdb5b3e55274
claim_branch: null
work_branch: agent/codex/T-610
write_paths:
- docs/tasks/T-610.md
- app-modern/ui/gio/
- app-win7/ui/gio/
- docs/routes.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/review/phase2-review.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
modern 与 Win7 的 `ui/gio/shell.go` 分别达到 959 行和 880 行,同时承载 `AppShell` 状态与快照生命周期、根布局编排、输入 drain、header/category/view 导航、catalog/list/row/icon、detail 和样式/文案 helper。代码图也把 `Layout`、`layoutContent`、`layoutAppRow`、`layoutDetail` 与 `panel` 识别为同一高耦合热点。继续在单文件中增加下载、设置或授权视图,会扩大冲突范围和两套隔离 Gio 适配器的人工 diff 噪声。
双端平行实现是既定架构取舍:modern 锁定 Gio v0.10.1,Win7 锁定 Gio v0.6.0,布局细节和可用 API 存在有意差异。本任务不尝试共享 Gio 控件代码,只在各自现有 `gio` package 内按一致职责拆文件,让后续改动有明确落点。T-608 已提供双端适配器交互契约,T-609 已冻结列表快照生命周期,当前保护面足以约束纯组织性移动。
本任务是行为不变的维护性重构,不交付新界面或业务能力,也不借拆分修正视觉、文案、事件时序或模型语义。
## 方案
1. 在移动前用代码图记录两个 `shell.go` 的声明清单和调用关系,把现有公开/未公开符号作为重构基线;移动后再次核对每个符号恰有一个定义,没有遗漏、复制或意外改名。
2. 在 modern 与 Win7 的 `ui/gio` package 中采用相同文件职责:
- `shell.go`:保留 `AppShell` 状态、构造、`ApplyIcon`/`SetItems` 生命周期、根 `Layout` 与 `drainInput` 编排。
- `shell_header.go`:承载 header、category、view/filter 导航和 footer 等顶部/导航职责。
- `shell_catalog.go`:承载 content/catalog、虚拟列表、app row、icon 与 empty state;行控件状态跟随该职责。
- `shell_detail.go`:承载 detail 布局及其字段、动作和 fallback/reason helper。
- `shell_style.go`:承载 palette/theme、panel 绘制、view/status 文案与颜色 helper。
modern-only 的 `layoutCatalog`、`layoutFooter`、`actionLabel` 等放入对应职责文件,Win7 不为追求文本一致而增加空壳或复制 modern 实现。
3. 只移动完整声明并收敛各文件 import,保持 package 名、接收者、函数签名、常量值、控件实例、map/list 所有权和调用顺序不变;执行 `gofmt`,不做顺手重命名或逻辑整理。
4. 冻结根布局与交互不变量:
- 每帧仍先 drain application/UI input,再按原顺序布局 header、content/detail/footer。
- 搜索、分类、view、行点击、关闭详情、空状态恢复和虚拟列表 viewport 的事件处理顺序不变。
- `rowControls` 继续按 app ID 保持,删除项/category controls 继续释放;`layout.List.Position` 与 selection/detail 上下文不重置。
- 图标请求身份、UI goroutine drain、`ApplyEvent`/`ApplyIcon`、过期结果拒绝和 ImageOp 剪枝规则不变。
- Layout 继续无 IO,所有尺寸、颜色、圆角、间距、控件顺序、可见文案和语义标签不变。
5. 复用 T-608 适配器契约、T-607 图标事件测试和既有 shell 测试;双端分别重复运行 UI 测试,再执行完整隔离 workspace 构建闸门。测试只在发现现有保护面无法观察拆分不变量时补充,不得为新文件布局复制 ViewModel 纯逻辑测试。
6. 同步架构、路由和编码规则,记录双端 shell 文件职责、同 package 边界和未来 UI 变更的落点;审核追踪与当前状态在任务完成时更新。
## 验收要点
- modern 与 Win7 均存在 `shell.go`、`shell_header.go`、`shell_catalog.go`、`shell_detail.go`、`shell_style.go`,职责镜像且仍属于各自 `gio` package;允许版本特有声明只出现在一端。
- 两个 `shell.go` 只保留 AppShell 状态/生命周期与根编排,不再定义 catalog row/icon、detail 或 panel/style helper;原声明清单中的每个符号在各自 package 内恰有一个定义。
- `NewAppShell`、`NewTheme`、`Layout`、`SetItems`、`ApplyIcon` 等既有可调用 API、签名和接收者不变;不新增跨 workspace import、共享 Gio package 或兼容 shim。
- `git diff` 可解释为声明移动、import 收敛和必要文档同步;无颜色/尺寸/文案、控件顺序、事件 drain、列表/selection、图标或快照语义变化。
- modern Go 1.25.0 与 Win7 Go 1.20.14 下 `go test -count=10 ./ui/gio` 分别通过;T-608 的 Editor/Clickable、AppID、detail context、500 项 viewport、controls lifecycle 和空状态矩阵保持全绿。
- 双端图标事件/过期拒绝测试保持通过;完整 `./scripts/verify_phase0.ps1` 通过,继续证明 Gio v0.10.1/v0.6.0 隔离及 Windows amd64 双目标可构建。
- `python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。
## 边界(不改什么)
- 不增加下载、安装、设置、授权或其他新视图,不改变任何可见 UI、交互、可访问文案、窗口尺寸或主题。
- 不拆分 `AppShell` 为多个状态对象,不重写布局算法,不调整事件处理、列表虚拟化、控件生命周期、图标生命周期或 `VisibleItems` generation 契约。
- 不提取跨 modern/win7 的 Gio 共享层,不让任一 workspace import 另一端,不升级或统一 Go/Gio 版本。
- 不修改 core/domain/application、平台层、命令入口、协议或 Schema,不恢复 T-302,不顺带处理 `ErrIconCacheUnsafe` 诊断/quarantine。
- 不以文件行数为目标制造过度碎片;验收以职责边界和行为不变为准,不设机械最大行数。
- 不修改、提交或删除用户的 `soft_quay.code-workspace`。
## 协作约束
- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。
- 本任务只允许修改 frontmatter 中的 `write_paths`;若拆分暴露必须改行为才能通过的既有缺陷,停止并记录,不得在 T-610 内扩边修复。
- T-610 完成、完整验证并提交前,不落成或领取 unsafe cache 诊断、Phase 1 后续整改或 T-302。
## 执行记录
- 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第五优先级维护项落成任务;现有全局最大任务为 T-609,因此取 T-610,依赖已完成的 T-609。
- 2026-07-17:代码图确认 modern `shell.go` 为 959 行、29 个声明,Win7 `shell.go` 为 880 行、25 个声明;两端都把状态/根编排、header、catalog/list、detail 和 style 聚合在单文件,主要差异为 modern 独有的 `layoutCatalog`、`layoutFooter` 与 `actionLabel` 等实现。
- 2026-07-17:冻结为各自 `gio` package 内五文件镜像职责拆分;保留版本特有差异,不共享 Gio 代码、不改变行为,并以 T-607/T-608/T-609 已有事件、适配器和 snapshot 契约作为回归保护面。
- 2026-07-18:在 `agent/codex/T-610` 分支领取任务,基线为 `75b1803564dd95284dc5bc25719ccdb5b3e55274`;保持单 Agent 串行执行。
- 2026-07-18:基线 `./init.ps1` 通过,包含治理/上下文/边界/依赖版本检查、Go 1.20.14 core vet/test、modern Go 1.25 与 Win7 Go 1.20.14 的 UI/平台测试和 Windows amd64 构建。
- 2026-07-18:modern 与 Win7 各自新增 `shell_header.go`、`shell_catalog.go`、`shell_detail.go`、`shell_style.go`;根 `shell.go` 分别从 959/880 行收敛到 186/190 行,只保留 AppShell 状态/构造、SetItems/ApplyIcon 生命周期、根 Layout 与 drainInput。所有函数体按完整声明机械移动并由 `gofmt` 收敛 import,未改名或调整调用顺序。
- 2026-07-18:重新索引代码图后确认拆分前后的声明总数均为 54,每个 qualified name 恰有一个定义;modern 的 Layout 仍直接调用 drainInput/header/content/footer,Win7 仍直接调用 drainInput/header/content,二阶 catalog/detail/style 调用链保持不变。
- 2026-07-18:modern Go 1.25.0 与 Win7 Go 1.20.14 的 `go test -count=10 ./ui/gio` 分别通过;T-608 适配器矩阵、T-607 图标事件/过期拒绝及既有 shell 测试无需修改。
- 2026-07-18:完整 `./scripts/verify_phase0.ps1` 通过,覆盖治理/上下文/链接/任务校验、core 边界与 Go/Gio pin、Go 1.20.14 core vet/test、modern/Win7 UI 与平台测试及 Windows amd64 双目标构建;路由、架构、编码规则、审核追踪和当前状态已同步。
- 2026-07-18:提交前 modern/Win7 `go vet ./ui/gio`、`validate_agent_context.py`、`validate_harness_governance.py` 与 `git diff --check` 均通过;工作区唯一范围外文件仍是用户未跟踪的 `soft_quay.code-workspace`,未修改或暂存。
+100
View File
@@ -0,0 +1,100 @@
---
id: T-611
title: 建立不安全图标缓存诊断与人工恢复指引
phase: 2
deps: [T-610]
status: DONE
created: 2026-07-18
issue: null
context_ref: 6455fec8115497ba73fc092366193ae67269b1bb
claim_branch: null
work_branch: agent/codex/T-611
write_paths:
- README.md
- docs/README.md
- docs/tasks/T-611.md
- core/catalog/
- app-modern/ui/gio/
- app-win7/ui/gio/
- docs/troubleshooting.md
- docs/api.md
- docs/routes.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/review/phase2-review.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
`core/catalog.IconCache` 已对磁盘缓存执行 `os.Lstat`:entry 是 symlink 或非普通文件时返回 `ErrIconCacheUnsafe`,`loadUncached` 立即 fail closed,不删除该 entry、也不回退远端。`IconEventDelivery` 已把该错误归类为稳定且不含敏感信息的 `application.IconFailureUnsafe` (`unsafe_cache`),`IconFailed` 身份中已有 request ID、app ID、规范 icon reference 与 DPI。这个安全决策本身合理,不应改成自动修复。
当前缺口位于上层诊断:modern/win7 的 `ApplyEvent` 只把 failure code 保存到 map,`IconFailure` 也只有测试调用;列表/详情没有显示 `unsafe_cache`,用户或运维无法区分普通离线占位与被安全策略阻断。缓存层也没有从真实非普通 entry 一直贯通到 `IconFailed` 的集成测试。与此同时 `IconCache.Load`/`IconEventDelivery.LoadAndPublish` 仍无生产调用者,因此不能把单元测试契约描述成已经接入真实日志或网络图标装配。
本任务关闭最终审核中的观察项:用现有稳定事件字段形成可定位但不泄密的诊断,在双端详情显示明确的 fail-closed 与人工恢复指引,并增加运维 runbook。它不自动删除、rename 或 quarantine 可疑对象,也不顺带接入生产 Fetcher。
## 方案
1. 用真实缓存文件系统条件固化 fail-closed:
- 在 `core/catalog` 创建请求对应路径为目录或其他确定的非普通 entry,调用真实 `IconCache.Load`,断言返回链可 `errors.Is(ErrIconCacheUnsafe)`、Fetcher 零调用、entry 未删除/改写且未发生远端回退。
- 在平台允许创建 symlink 时补同样矩阵并验证外部 target 未读取/改写;权限不允许时可以明确 skip,但跨平台非普通 entry 用例必须执行。
- 用真实 `IconCache` 作为 `IconEventDelivery.Loader`,证明同一错误发布 `IconFailed/unsafe_cache`,同时后台返回值仍保留 `ErrIconCacheUnsafe`;event payload 不包含原始 error、绝对路径、URL/query 或 symlink target。
2. modern 与 Win7 使用相同的本地 failure state 保存最近一次已验证失败的完整 `IconEventIdentity` 与 `IconFailureCode`,而不是只保存 code:
- 保持现有 `IconFailure(appID)` 查询签名兼容;详情布局直接读取同 package 状态。
- 只有 request ID、app ID、icon reference 与 DPI 都匹配当前最新请求时才能写入诊断。
- `ExpectIcon` 新 generation、匹配的 `IconReady`/`ApplyIcon`、IconRef 变化、取消和 app 删除按现有生命周期清除诊断;同引用 Catalog 更新可保留当前 failure。
3. 仅对 `IconFailureUnsafe` 在选中软件详情中显示持久、可访问的安全提示:
- 明确“缓存项未被使用、本次请求没有继续远端获取或自动修复”,提示退出 SoftBox 后按故障排查文档人工处理。
- 显示稳定诊断码、app ID 与由规范 digest + DPI 组成的缓存 locator;不得显示原始 URL/query、后台 error、symlink target 或未经验证的路径文本。
- 不增加“自动清理/重试/quarantine”按钮;`unavailable`/`invalid_content` 继续使用现有占位行为,避免把普通离线或坏内容误报为安全事件。
- 提示位于 `shell_detail.go`,modern/Win7 保持相同语义和可访问文本,视觉可遵循各自 Gio 版本既有样式。
4. 扩展双端 UI 回归:
- 注入当前请求的 `unsafe_cache` event,打开对应详情,通过 Gio 语义树断言安全提示、code/app/locator 可见且没有敏感原始错误。
- 覆盖迟到/换引用/换 DPI/取消/删除 app 不产生提示,新请求或成功 ready 清除旧提示,同引用 `SetItems` 保留诊断。
- 保持 T-607 最新请求/过期拒绝和 T-608 详情上下文/虚拟化契约通过。
5. 新增 `docs/troubleshooting.md` 的“不安全图标缓存”人工恢复 runbook:
- 解释诊断字段,把 locator 映射为 `<configured-icon-cache-root>/<digest>-<dpi>.icon`;安装模式正式装配的目标根为 `%LOCALAPPDATA%/OwnSoftBox/cache/icons/`,但在生产装配完成前明确以传给 `NewIconCache` 的 root 为事实,不虚构当前 cmd 已使用该路径。
- 要求先完全退出 SoftBox,确认 cache root 位于预期数据根且 root 本身不是 reparse point,不要打开/跟随可疑 entry 的 target;由管理员在根的父目录侧人工移走已核验的图标缓存目录或处理单个 entry,保留诊断信息后再启动重建。
- 明确普通用户拿不准时停止并联系管理员;不提供递归跟随链接的删除命令,不把人工步骤包装成应用内自动操作。
6. 同步 API、架构、路由和编码规则,冻结 `unsafe_cache` 的稳定字段、UI 语义、runbook 入口与“生产 IconCache/Fetcher 装配仍待后续任务”的真实状态。
## 验收要点
- 真实目录/非普通 cache entry 使 `IconCache.Load` 返回 `ErrIconCacheUnsafe`,Fetcher 不调用、entry 不删除/改写、无远端回退;可创建 symlink 的环境同时证明外部 target 不受影响。
- 真实 cache → delivery 路径发布唯一 `IconFailed` 且 code 为 `unsafe_cache`;后台返回错误仍可 `errors.Is(ErrIconCacheUnsafe)`,取消语义不变。
- application event/UI 不携带或显示原始 error、绝对 cache root、URL/query、token 或 link target;可定位字段仅含稳定 code、request/app 身份、规范 SHA-256 reference 与 DPI。
- modern/Win7 对当前 selected app 的 unsafe failure 显示等价、可访问的 fail-closed 与人工处理提示;普通 unavailable/invalid 不冒充安全告警。
- 最新请求、换 reference/DPI、取消、删除、ready 与同引用 snapshot 更新的诊断状态生命周期有双端测试;现有 `IconFailure(appID)` API 保持兼容。
- `docs/troubleshooting.md` 明确 locator→文件名、目标 cache root、停进程/不跟随 target/管理员处理/重启验证步骤,并如实标注当前 production IconCache/Delivery 尚未装配。
- 不新增自动 delete/rename/quarantine/retry 代码,不让不安全 entry 触发网络回退,不降低现有 fail-closed 判断。
- Go 1.20.14 + `GOWORK=off` 下 `go vet ./catalog`、`go test -count=10 ./catalog` 通过;modern Go 1.25.0 与 Win7 Go 1.20.14 下 `go test -count=10 ./ui/gio` 分别通过。
- 完整 `./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。
## 边界(不改什么)
- 不接入真实 HTTP IconFetcher、CDN 映射、生产 `NewIconCache`/`LoadAndPublish` 调用方或后台任务调度;当前无生产调用者的事实必须保留在文档。
- 不把 raw error、绝对路径、URL/query、token 或 symlink target 加入 application event/持久状态/UI;不建立遥测或上传诊断。
- 不自动删除、rename、隔离或修复 symlink/reparse point/非普通 entry,不新增一键清理按钮;自动 quarantine 仍需独立威胁模型与 Windows 攻击测试。
- 不修改图标并发、LRU、大小/尺寸校验、磁盘命名、Fetcher 流式上限或 Gio UI 线程事件投递契约。
- 不把普通损坏文件、离线、hash/decode 失败提升为 `unsafe_cache`;现有损坏普通文件删除重取策略保持不变。
- 不修改下载/安装/授权流程、协议 Schema、Go/Gio 版本、workspace 或 T-302;不修改、提交或删除用户的 `soft_quay.code-workspace`。
## 协作约束
- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。
- 本任务只允许修改 frontmatter 中的 `write_paths`;若实现需要生产网络装配、自动文件处置或新增事件敏感字段,必须停止并另立任务,不得在 T-611 内扩边。
- T-611 完成、完整验证并提交前,不落成或领取 Phase 1 后续整改或 T-302。
## 执行记录
- 2026-07-18:根据 `docs/review/phase2-review.md` 最终处理顺序第 6 项落成任务;现有全局最大任务为 T-610,因此取 T-611,依赖已完成的 T-610。
- 2026-07-18:代码图确认 `loadDisk` 已拒绝 symlink/非普通 entry,`loadUncached` 遇 `ErrIconCacheUnsafe` 直接返回且不 fetch;`classifyIconFailure` 已映射为 `unsafe_cache`,`IconFailedPayload` 已含安全的 reference/DPI,无需扩展 raw error 字段。
- 2026-07-18:调用图确认 `IconEventDelivery.LoadAndPublish` 与双端 `IconFailure` 目前都只有测试调用;双端 `ApplyEvent` 只保存 failure code 且布局不读取。任务因此采用“真实 cache→event 集成测试 + UI 安全提示 + 人工 runbook”,明确不宣称生产装配已完成。
- 2026-07-18:现有 `docs/api.md` 已冻结磁盘名 `<digest>-<dpi>.icon` 与 `unsafe_cache` 稳定枚举,架构只定义 `%LOCALAPPDATA%/OwnSoftBox/cache/` 逻辑根;T-611 将细化正式 icon root 与 locator 映射,同时保留 constructor root 才是当前代码事实。
- 2026-07-18:新增 `docs/troubleshooting.md` 后治理闸门要求根 README 与 docs README 登记所有文档;将这两个纯导航文件补入 `write_paths`,不扩大功能范围。
- 2026-07-18:core 新增真实目录与 Windows symlink cache entry 用例,证明 `ErrIconCacheUnsafe` 可识别、Fetcher 零调用、entry/外部 target 不删除不改写;真实 `IconCache → IconEventDelivery` 只发布一个 `IconFailed/unsafe_cache`,后台错误链保留 sentinel 且 event 不含 cache root/raw error。
- 2026-07-18:modern/Win7 将 failure map 从单 code 升级为完整 `IconEventIdentity + IconFailureCode`,保持 `IconFailure(appID)` 兼容;详情只为当前 `unsafe_cache` 显示文字标题、fail-closed/人工恢复说明和安全 code/app/`<digest>-<dpi>.icon` locator。语义树回归覆盖普通失败不冒充告警以及新请求、迟到事件、ready、DPI/reference 变化、取消、删除和同引用 snapshot 生命周期。
- 2026-07-18:新增不跟随链接的管理员 runbook,同步 API、路由、架构、编码规则和导航;明确安装目标 root 与当前生产 IconCache/Delivery/Fetcher 尚未装配的真实边界,未增加自动 delete/rename/quarantine/retry。
- 2026-07-18:验证通过:`GOWORK=off go vet ./catalog`,`GOWORK=off go test -count=10 ./catalog`,modern Go 1.25.0 与 Win7 Go 1.20.14 各 `go test -count=10 ./ui/gio`;Windows symlink 子用例实际 PASS;`./scripts/verify_phase0.ps1`、agent-context、harness governance 与差异检查全部通过。
+74
View File
@@ -0,0 +1,74 @@
---
id: T-612
title: 在 ZIP 打开前限制包大小与中央目录元数据
phase: 1
deps: [T-605]
status: DONE
created: 2026-07-18
issue: null
context_ref: 320b83d929fde7d51fc5bb3e9fbdaac7beb5101e
claim_branch: null
work_branch: agent/codex/T-612
write_paths:
- docs/tasks/T-612.md
- core/installer/
- docs/api.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/review/phase1-security-review.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
`Extractor.ExtractFile` 当前直接调用 `zip.OpenReader(zipPath)`,而现有 `preflight` 对 `len(archive.File)`、展开大小和压缩比的检查发生在标准库已经读取并解析 ZIP 中央目录之后。攻击者若能让已验证的发布包携带巨大或伪造的中央目录元数据,仍可在协议级检查开始前触发不受本模块限制的元数据读取/分配。根据 `docs/review/phase1-security-review.md` 定稿,这属于“签名内容管线的纵深防御缺口”,不是未签名网络输入可直接利用的路径,但仍阻断 T-302 正式安装整合。
T-301 已把 known-total 下载限定为精确长度,并在 final `.download` 发布前核对普通文件身份与实际长度;Catalog `packages[arch].size` 也是签名内容。当前 `Extractor` 只有测试调用方,T-302 尚未把 Catalog、下载完成文件、SHA-256 与解压器编排为生产链路。因此本任务应先把三者对账所需的 installer API 和 fail-closed 边界落实,不得把测试契约误写成生产安装已接线。
## 方案
1. 扩展 `installer.Limits`,增加 ZIP 原始包大小和中央目录字节数两个显式硬上限;默认原始包上限与 T-301 `DefaultMaxUnknownBytes` 的 4 GiB 上限一致,中央目录采用独立、明显更小的暂定安全上限。`NewExtractor` 必须拒绝零值、负值、溢出或彼此矛盾的配置;现有条目数、展开体积和压缩比硬上限保留。
2. 把 `Extractor.ExtractFile` 的调用契约改为显式接收已验签 Catalog 的 `expectedPackageSize`。在任何 ZIP 解析前,它必须:
- 打开并只使用同一个普通文件句柄,以该句柄的真实长度同时核对 `expectedPackageSize`、`MaxArchiveBytes` 与后续 ZIP reader 的 size;不允许先扫描路径再按同一路径重新打开,避免本地替换窗口。
- 拒绝未知、非正或不匹配的预期大小,以及符号链接/非普通包文件;返回可由 `errors.Is` 识别的稳定 installer 错误,且不创建 staging。
- 从文件尾部以固定上界搜索 EOCD(最多 ZIP 规范允许的 65,535 字节 comment 加 EOCD 固定字段),不把整个文件或整个中央目录读入内存。校验单磁盘语义、EOCD 位置、中央目录 offset/size/entries 的无溢出区间关系和中央目录必须在 EOCD 前结束。
- 支持并严格校验 ZIP64 locator/ZIP64 EOCD 的对应字段;不因合法 ZIP64 仅因 32 位 EOCD 哨兵值而误拒绝。无法完整证明一致性的 EOCD、跨盘 archive、截断、伪造 offset 或不支持的结构一律按无效 archive 拒绝。
- 在构造 `zip.Reader` 前以 EOCD 声明条目数和中央目录字节数分别执行 `MaxEntries` 与 `MaxCentralDirectoryBytes` 检查;再使用已经扫描的同一打开句柄和受核对的实际长度创建 `zip.NewReader`。现有完整 `preflight` 必须保留,作为对标准库解析结果、ZIP entry 语义和展开数据的第二道检查。
3. 冻结错误及 API 文档:大小不一致、原始包过大、中央目录过大、声明条目过多和格式错误要有可测试的 stable sentinel/错误链;调用方只能将来自已验签 Catalog 的 exact `size` 传入。T-302 必须重新取得 Catalog 身份并把已完成 `.download` 的实际长度、Catalog `size` 与本 API 的预扫描结果串成同一条安装链。
4. 添加攻击回归,至少覆盖:正常经典 ZIP;真实包大小与 expected size 不一致;原始文件超限;经典 EOCD 伪造 entries 或 central-directory size;越界/截断/跨盘 EOCD;ZIP64 成功路径及不一致 locator/record;以及被预扫描拒绝时 destination 不存在。测试要断言错误发生在 `zip.NewReader`/旧 `preflight` 之前能够确定的 sentinel 上,而非退化为“解析后才失败”。保留并回归现有路径逃逸、压缩比、展开量、CRC 和 staging 清理测试。
5. 同步 API、架构和编码规则:把“先对账已验证 Catalog size、已完成普通下载文件长度,再作有界 EOCD/中央目录预扫描,最后才构造 ZIP reader”列为安装安全顺序;明确限额仍是暂定值,T-302 按真实包分布复核。Phase 1 审核记录和项目当前状态必须如实标记该阻断项已关闭或仍待 T-302 接线。
## 验收要点
- `Limits` 同时包含并验证 `MaxArchiveBytes`、`MaxCentralDirectoryBytes`、现有 `MaxEntries`、展开大小与压缩比;默认原始包上限与 T-301 4 GiB unknown-total 限制一致,中央目录限制独立且不允许绕过。
- `ExtractFile` 在任何 `zip.Reader` 构造前,以同一普通文件句柄验证 expected Catalog size 与真实文件长度相等、且不超原始包上限;不匹配/未知/非普通输入 fail closed,不创建 destination。
- EOCD 预扫描只读取固定上界尾部和 ZIP64 所需固定记录,不分配与 archive 长度或声明目录大小成比例的内存;经典与 ZIP64 的 entries、offset、size、磁盘号、边界和溢出均被验证。
- EOCD 声明 entries 超 `MaxEntries` 或中央目录超 `MaxCentralDirectoryBytes` 时,返回对应稳定错误且不进入标准库中央目录解析;损坏、截断、跨盘或不一致的 ZIP64 结构返回 `ErrInvalidArchive` 错误链。
- 有效的经典 ZIP 和 ZIP64 元数据路径仍能完成现有安全提取;现有路径、entrypoint、类型、重复名、CRC、展开体积和压缩比防线不回退。
- `docs/api.md`、`04-architecture.md`、`05-coding-rules.md` 和审核记录准确写明 size 三方对账、同句柄预扫描、ZIP64 策略、临时限额与“生产 T-302 接线尚未完成”的边界。
- Go 1.20.14 + `GOWORK=off` 下 `go vet ./installer`、`go test -count=10 ./installer` 通过;完整 `./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。
## 边界(不改什么)
- 不实现 T-302 的下载→SHA-256→Catalog 身份→app.json→安装编排,不把 `core/downloader` 或 `cmd` 误改成已有生产调用方;本任务只提供 T-302 必须使用的 Extractor 契约和测试证据。
- 不改 Catalog 签名、SHA-256、下载队列协议、manifest/schema 或 package 格式;不从 ZIP 内部字段放宽 Catalog 的预期大小。
- 不移除既有中央目录语义 `preflight`,不降低 Windows 安全路径、entrypoint、压缩比、展开量、CRC、全新 staging 或失败清理防线。
- 不在本任务实现 payload 文件 `Sync()`、目录/journal 的断电耐久顺序或 Windows VM 故障注入;这是审核顺序下一项的独立任务。
- 不引入第三方 ZIP 解析库,不读取完整 archive/中央目录到缓冲区,不改 Go/Gio 版本、workspace、UI、用户的 `soft_quay.code-workspace` 或无关任务。
## 协作约束
- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。
- 本任务只允许修改 frontmatter 中的 `write_paths`;若需要生产安装编排、下载队列协议变化、SHA-256 再校验或断电耐久实现,必须停止并另立任务,不得在 T-612 内扩边。
- T-612 完成、完整验证并提交前,不落成或领取下一项 Phase 1 耐久性整改或 T-302。
## 执行记录
- 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 2 项落成任务;现有全局最大任务为 T-611,因此取 T-612,依赖已完成的 T-605。
- 2026-07-18:代码图确认 `ExtractFile` 直接调用 `zip.OpenReader`,随后才在 `preflight` 用 `len(archive.File)` 检查条目数;其调用方目前均为 installer 测试。T-301 已对 known-total 下载以已验签 Catalog size 严格限长并发布普通 final 文件,但尚无生产安装编排。因此本任务通过显式 `expectedPackageSize` 和同句柄预扫描建立正确接口,不宣称已完成 T-302 接线。
- 2026-07-18:默认 `MaxArchiveBytes` 将与 downloader `DefaultMaxUnknownBytes` 的 4 GiB 保持一致;中央目录单独限额及 ZIP64 兼容性必须以测试和文档冻结,真实包采样后的阈值复核仍留给 T-302。
- 2026-07-18:实现 `MaxArchiveBytes=4 GiB`、`MaxCentralDirectoryBytes=64 MiB` 和对应配置校验;`ExtractFile` 现在强制接收 expected Catalog size,打开同一普通文件后先核对大小并执行有界 EOCD/ZIP64 扫描,再用该句柄构造 `zip.NewReader`。声明 entries/central size 在标准库解析前受限,跨盘、截断、越界与不一致 ZIP64 结构 fail closed,既有完整 `preflight` 保留。
- 2026-07-18:installer 回归新增 size 不一致、原始包超限、经典 EOCD 条目/中央目录伪造、跨盘、越界、截断、ZIP64 成功/损坏及非普通输入矩阵,全部确认拒绝时不创建 staging;既有路径、CRC、展开量、压缩比与 destination 清理回归继续通过。
- 2026-07-18:验证通过:`GOWORK=off go vet ./installer`、`GOWORK=off go test -count=10 ./installer`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前 `git diff --check`;完整 Phase 0 闸门同时复跑 core 全包、modern/Win7 UI 测试与双目标构建。
+83
View File
@@ -0,0 +1,83 @@
---
id: T-613
title: 建立安装文件与目录事务耐久顺序
phase: 1
deps: [T-612]
status: DONE
created: 2026-07-18
issue: null
context_ref: 0f69fa3bb4d00e6c79938c0a7bc66f5e0faec09e
claim_branch: null
work_branch: agent/codex/T-613
write_paths:
- docs/tasks/T-613.md
- core/installer/
- docs/api.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/review/phase1-security-review.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
T-103 已建立 `staging → current → backup` 的进程崩溃恢复状态机,但它只证明了关键步骤之间的进程退出可恢复,没有建立真实断电时的数据与目录项耐久顺序。当前 Extractor 在 `io.Copy` 后直接关闭 payload 文件;`transaction.go` 虽会同步临时 journal 内容,但 journal rename、journal 删除、目录 rename 以及 `RemoveAll` 后都没有父目录持久化栅栏。发生掉电时,`committed` journal、payload 数据、`current`/`backup` 目录名和清理动作可能以不安全的相对顺序落盘。
根据 `docs/review/phase1-security-review.md` 最终处理顺序第 3 项,这是 T-302 的独立阻断项。Windows 不存在与 POSIX 完全等价的目录 fsync;本任务必须把可用的 Windows 目录句柄 `FlushFileBuffers` 策略落实为可测试的实现,且在无法建立所需栅栏时 fail closed。单元测试能够证明调用顺序和错误传播,不能声称替代 Windows VM/真机的物理断电、文件锁或杀毒软件故障注入。
## 方案
1. 在 `core/installer` 建立单一、可测试的内部耐久接口/栅栏,由 Extractor、transaction、Switcher、Recovery 和受控删除路径复用:
- 普通文件在写入、CRC/长度校验成功后必须 `Sync` 再关闭;Sync/Close 任一失败使本次 staging 删除并返回错误,不得报告可安装结果。
- staging 完整提取后,按子目录到根目录的顺序同步目录树,保证已创建文件、目录和 metadata 在 Switcher 写 `prepared` journal 前已经经过目录项栅栏。
- 写 journal 继续采用临时文件→内容 Sync→Close→rename;目标/backup rename、backup 删除和最终 journal 删除后都要同步 app root。`writeTransaction` 成功的语义必须是对应 phase 已经完成文件与目录栅栏。
- 每次 `current ↔ backup ↔ staging` 目录 rename、rollback/recovery rename 和 `removeManagedDirectory` 完成后,先同步 app root,再运行测试钩子或写下一 phase journal。已 `committed` 并持久化前不得删除 backup;删除 backup 与 transaction 后也要完成 root 栅栏。
2. 用 build tag 分离目录栅栏实现,不引入第三方依赖:
- 非 Windows 使用打开目录后的 `File.Sync`。
- Windows 使用 `syscall.CreateFile` 的 `FILE_FLAG_BACKUP_SEMANTICS` 打开目录句柄并调用 `FlushFileBuffers`;只使用 Win7 已存在的 API,不让现代 API 进入导入表。目录打开、flush 或 close 失败必须返回稳定 installer 耐久错误,不得静默降级为 no-op。
- 所有真实文件系统操作保持在 installer 内,不把平台 API 泄露给 domain/application 或 Gio。测试用可注入 fake fence 精确断言顺序和故障传播;Windows 原生测试必须验证真实目录 fence 可执行。
3. 固化顺序与恢复语义:
1. 每个 payload 的内容已验证、Sync、Close;
2. staging 子目录至根目录已 Sync;
3. `prepared` journal 以临时文件内容 Sync + root 栅栏持久化;
4. current→backup rename + root 栅栏 → `current_backed_up` journal + root 栅栏;
5. staging→current rename + root 栅栏 → `staging_activated` journal + root 栅栏;
6. health 成功后 `committed` journal + root 栅栏,才清理 backup 和 transaction;health 失败/Recovery 的反向 rename 与清理也遵循同一栅栏。
4. 新增耐久错误、单元与原生测试:覆盖 payload Sync/Close、staging tree、journal replace/remove、每个切换/rollback/recovery rename、目录清理的调用顺序;注入每一栅栏失败并断言不越过下一个 phase、现有恢复不变式仍成立、无误报成功。Windows 用例在真实目录上验证目录句柄 Flush;不支持/失败的文件系统必须显式失败。保留既有阶段崩溃、路径、ZIP、CRC 与恢复矩阵。
5. 同步 API、架构、编码规则和审核记录,明确“耐久栅栏”相对于“物理掉电证明”的边界。T-302/T-601 仍需在目标 NTFS/Windows VM 或真机执行断电、杀毒/文件锁等故障注入;本任务不把单元测试描述为完整硬件级持久化担保。
## 验收要点
- 每个成功提取的 payload 文件在 CRC/大小校验后、返回 `ExtractResult` 前已 Sync 并正确 Close;Sync/Close 失败删除 staging,不返回成功,不留下可被 Switcher 使用的半持久 payload。
- staging 目录树在 Extractor 成功返回前按子→父顺序经过目录栅栏;失败 fail closed。`prepared` journal 只可在该栅栏之后写入。
- 所有 journal replace/remove、`current/backup/staging` rename 和受控目录删除都使用单一耐久路径;每次可见目录变更先有 root 栅栏,再可写后续 phase/运行 afterStep。`committed` journal 经过 root 栅栏后才允许清理 backup 和 transaction。
- Windows 目录栅栏通过 `FILE_FLAG_BACKUP_SEMANTICS` 目录句柄 + `FlushFileBuffers` 实现且可在原生测试运行;不支持或 flush 失败返回稳定耐久错误,绝不静默跳过。非 Windows 无头测试继续可运行。
- fake fence 覆盖成功顺序与每一类栅栏故障:payload、staging tree、journal、rename、rollback/recovery、cleanup;故障不推进 phase,既有 `Recover` 状态机仍恢复或 fail closed。
- 文档准确区分“代码已建立并测试耐久顺序”与“尚需 T-302/T-601 在 Windows VM/真机完成物理断电、文件锁/杀毒干扰故障注入”;不再把目前单元测试写成硬件掉电证明。
- Go 1.20.14 + `GOWORK=off` 下 `go vet ./installer`、`go test -count=10 ./installer` 通过;当前 Windows 主机上的 installer 原生测试、完整 `./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。
## 边界(不改什么)
- 不实现 T-302 的下载、SHA-256、Catalog identity、app.json/files.json 校验、安装事件或生产 `cmd` 编排;不把现有 installer 原型宣称为完整用户安装链。
- 不修改 ZIP 协议、Catalog/下载队列、路径安全、中央目录预扫描、health-check 定义、UI、Schema、Go/Gio 版本或 workspace。
- 不引入第三方 durability 库,不使用 Win10+ API;Windows 只用 Win7 已有的 `CreateFile`/`FlushFileBuffers` 路径。
- 不声称单元测试、`Sync` 或 `FlushFileBuffers` 覆盖电源切断、硬件缓存、网络文件系统、文件锁或杀毒软件的所有行为;这类目标环境故障注入仍须 T-302/T-601 单独验证。
- 不修改、提交或删除用户的 `soft_quay.code-workspace`,不启动子 Agent,不在本任务外落成 T-614 或恢复 T-302。
## 协作约束
- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。
- 本任务只允许修改 frontmatter 中的 `write_paths`;若需要新的安装协议、下载链接线、外部 VM/真机基础设施或第三方依赖,必须停止并另立任务,不得在 T-613 内扩边。
- T-613 完成、完整验证并提交前,不落成或领取签名向量整改、T-302 或其他后续任务。
## 执行记录
- 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 3 项落成任务;现有全局最大任务为 T-612,因此取 T-613,依赖已完成的 T-612。
- 2026-07-18:本地代码检索确认 Extractor 在 `io.Copy` 后仅 Close payload,`replaceFileWithBackup` 只 Sync 临时 journal 内容,Switcher/Recovery 的目录 rename 与 `removeManagedDirectory` 后均没有父目录栅栏。任务因此覆盖 payload、目录树、journal、切换/恢复 rename 与清理的统一顺序,不只修单个 `Sync()`。
- 2026-07-18:Windows 策略限定为 Win7 已有的目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`,失败 fail closed;物理掉电/锁/杀毒故障注入的实测证据仍明确后置到 T-302/T-601。
- 2026-07-18:在 `core/installer` 落地单一内部耐久接口。Extractor 现在在每个 payload 的 CRC/长度检查后 Sync、Close,并按 staging 子目录→根→父目录建立栅栏;transaction 的临时 journal 先 Sync/Close,全部 journal rename/remove 都同步 app root。Switcher、rollback、Recovery 与受控目录删除复用同一路径,每次目录可见变更完成 root 栅栏后才可进入下一 phase/afterStep。
- 2026-07-18:Windows 目录实现使用 Go 1.20 标准 `syscall.CreateFile` 的读写目录句柄、`FILE_FLAG_BACKUP_SEMANTICS` 和 `FlushFileBuffers`,没有新增第三方或 Win10+ API;非 Windows 以打开目录的 `File.Sync` 实现。原生 Windows `TestSyncDirectoryPath` 通过,打开、flush、close 任一步失败均返回 `ErrDurability` 错误链。
- 2026-07-18:新增 fake fence 顺序/失败测试,覆盖 payload、staging tree、journal、rename、rollback、Recovery 和 committed cleanup;失败不越过下一 phase,既有 `Recover` 仍将可恢复状态收敛。同步完成 API/架构/编码规则/审核结论,明确代码层顺序不替代 T-302/T-601 的 VM/真机物理断电、文件锁与杀毒软件故障注入。
- 2026-07-18:验证通过:`GOWORK=off go -C core vet ./installer`、`GOWORK=off go -C core test -count=10 ./installer`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与 `git diff --check`;完整验证同时覆盖 Go 1.20.14 core、现代版与 Win7 版构建/测试。无 blocker。
+74
View File
@@ -0,0 +1,74 @@
---
id: T-614
title: 冻结 Catalog 规范化与签名跨实现测试向量
phase: 1
deps: [T-613]
status: DONE
created: 2026-07-18
issue: null
context_ref: 9e5f3f4840b03dc90342a9bde923c39d6178b029
claim_branch: null
work_branch: agent/codex/T-614
write_paths:
- docs/tasks/T-614.md
- core/catalog/
- testdata/catalog/
- testdata/README.md
- docs/api.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/review/phase1-security-review.md
- docs/00-ai-start-here.md
- docs/06-tasks.md
- docs/current-state.md
---
## 问题 / 背景
T-101 的 Catalog verifier 已能拒绝重复字段、尾随 JSON 和非整数数字,但它的合法签名测试由同一个客户端 `parseRestrictedJSON` / `canonicalJSON` 在测试期生成。这只能证明客户端实现自洽,不能发现发布端 `softbox-catalog` 与客户端对 Unicode、数字或 Base64 文本规则理解不同的问题。Phase 1 审核定稿的下一项要求在协议冻结前建立独立、固定的 canonicalization/签名测试向量。
现有实现也存在两处未冻结的歧义:Go JSON decoder 会把孤立 `\uD800` / `\uDC00` 替换为 U+FFFD,而 `base64.StdEncoding.Strict()` 仍接受 CR/LF。若不在解析和签名域边界显式拒绝,不同发布实现可能对相同文本产生不同签名字节或接受非唯一签名表示。`-0` 也必须明确处理,不能作为与 `0` 语义相同而字节不同的受限整数形式留在协议中。
本仓库不包含 `softbox-catalog` 发布端或生产私钥。因此本任务交付可由独立发布端消费的版本化 testdata corpus、公开测试公钥、固定 canonical bytes 与固定 Ed25519 签名;不把客户端运行时 canonicalizer 当成向量的生成器,也不宣称已经替外部发布端执行了集成测试。
## 方案
1. 在 `docs/api.md`、架构和编码规则冻结 Catalog 签名域的精确边界:
- JSON 字符串必须形成合法 Unicode scalar sequence;`\u` 高代理项必须紧跟低代理项,孤立低代理项或不完整/不匹配 pair 一律拒绝,不得替换为 U+FFFD。
- 数字只接受 `0`、正整数或负的非零整数;拒绝 `-0`、小数、指数、前导零和超出 JSON token 的其他表示。大整数按原始十进制 token 写入 canonical bytes,不转浮点。
- 顶层及 package 签名只接受标准 padded Base64 的唯一文本形式:解码后重新编码必须与输入逐字节相等,故 CR/LF、其他空白、缺/多 padding 一律拒绝。
2. 在 `core/catalog` 以共享的受限 JSON/签名辅助路径落实上述规则,不引入第三方 canonicalization、JSON 或 crypto 依赖。保留“只删除顶层 `signature`,嵌套 package `signature` 仍属于被签名 payload”的已有语义。
3. 在 `testdata/catalog/` 新建版本化静态 vector corpus 和说明,每个 vector 固定保存原始 document、预期 canonical signing bytes、测试公钥和 Ed25519 signature 或确定的拒绝分类。有效向量至少覆盖 Unicode 键排序、JSON 转义、`<>&`、U+2028/U+2029、合法 surrogate pair、`-0` 拒绝、大整数、嵌套 signature、标准 padding 和“同一语义的不同 JSON 表示得到相同 signing bytes”;拒绝向量至少覆盖孤立 high/low surrogate、非成对 surrogate、Base64 CR/LF/其他空白和 padding 异常。
4. 测试只读取 corpus 的静态 `signed_payload` / `signature` 作为预期值:先断言客户端输出的 canonical bytes 完全相等,再用 corpus 公钥验签。不得调用 `signCatalogPayload`、`canonicalJSON` 或测试期私钥去生成合法向量的期望值。现有 unit tests 可继续用于局部行为,但 corpus 必须是跨实现契约回归门。
5. 同步审核记录、路线图和当前状态。明确 T-614 关闭的是客户端协议契约与可交付的独立输入/输出向量;外部 `softbox-catalog` 必须在其仓库消费同一 corpus 并由发布流程证明通过,该外部动作不在本任务内。
## 验收要点
- `docs/api.md` 对 Unicode surrogate、整数 `-0`、大整数写法、Base64 padding/空白和仅顶层 signature 删除行为没有歧义;架构、编码规则与 corpus README 一致。
- `testdata/catalog/` 存在可机器读取、版本化的固定 corpus,只含虚构公开测试公钥与固定数据,不含生产私钥、真实 URL 或真实签名。有效 vector 的 expected signing bytes 和 Ed25519 signature 均为静态值;不同语义相同的 document 映射到同一静态 bytes/signature。
- corpus 覆盖 Unicode 键排序、字符串转义、`<>&`、U+2028/U+2029、合法 surrogate pair、非法 surrogate、`-0`、大整数、嵌套 signature、Base64 padding、CR/LF/其他空白及同语义不同 JSON 表示。
- Verifier 与 Parser 对孤立/不匹配 surrogate、`-0`、非唯一或非法 signature Base64 fail closed;嵌套 package signature 未被误删;合法向量在不重新签名的情况下通过。
- corpus 测试的 expected canonical bytes/signature 不由当前 canonicalizer 或运行时私钥生成;测试通过静态 public key 与固定向量验证,从而能发现客户端与发布端的字节级漂移。
- `GOWORK=off go -C core vet ./catalog`、`GOWORK=off go -C core test -count=10 ./catalog`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。
## 边界(不改什么)
- 不实现或伪造外部 `softbox-catalog` 发布器、生产密钥管理、密钥 ID/轮换、package 独立签名域、HTTPS 请求、Catalog UI 或 T-302 安装整合。
- 不更改 Catalog manifest/app/package Schema、字段、channel/filter 语义、缓存策略、下载队列、ZIP 安装、Gio 或 Go/Gio 工具链。
- 不引入第三方 JSON canonicalization/crypto 库,不为了兼容旧的非规范签名文本而放宽拒绝规则。
- 不把 corpus 的客户端测试表述为外部发布端已消费或全链路发布证明;外部仓库接入和 CI 证据需另行协调。
- 不修改、提交或删除用户的 `soft_quay.code-workspace`,不启动子 Agent,不提前落成或领取 T-302。
## 协作约束
- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。
- 本任务只允许修改 frontmatter 中的 `write_paths`;若需要外部发布仓库提交、生产密钥、真实发布签名或协议字段/密钥轮换,必须停止并另立任务或请求外部协调。
- T-614 完成、完整验证并提交前,不落成或领取 T-302;T-302/T-601 的 Windows VM/真机断电、文件锁/杀毒干扰验证仍不因本任务被省略。
## 执行记录
- 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 4 项落成;T-613 已完成,因此 T-614 成为下一项唯一任务。
- 2026-07-18:前置检索确认现有合法签名测试由同一客户端 canonicalizer/测试私钥生成,Go JSON decoder 会将非法 surrogate 替换为 U+FFFD,且标准库 strict Base64 仍容忍 CR/LF。任务据此冻结拒绝策略和静态跨实现 corpus,不把客户端自举测试误作发布端互操作证明。
- 2026-07-18:新增 `testdata/catalog/canonical-vectors.json` v1 和客户端 corpus 测试。有效向量固定断言 canonical bytes、顶层签名和 RFC 8032 公开测试公钥验证结果,不调用 `canonicalJSON` 或测试期私钥生成预期;拒绝向量覆盖孤立/不匹配 surrogate、`-0`、Base64 CR/LF/space/tab 与 padding 缺失/额外。两种 key order/空白不同的 JSON 固定映射到同一 bytes/signature。
- 2026-07-18:在受限 JSON 解析器增加 surrogate 扫描,拒绝 Go decoder 原会替换的非法 pair;整数 token 只接受 `0`、正整数或负非零整数。Verifier 和 Parser 共享 Base64 decode→reencode 相等校验,因此非唯一文本不再被接受;嵌套 `signature` 仍在 signed payload。
- 2026-07-18:验证通过:`GOWORK=off go -C core vet ./catalog`、`GOWORK=off go -C core test -count=10 ./catalog`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与 `git diff --check`;完整验证同时覆盖 Go 1.20.14 core、现代版与 Win7 版构建/测试。外部 `softbox-catalog` 的 corpus 消费和 CI 证据不在本仓库,已作为跨仓库待协调项如实保留,无当前代码 blocker。
+50
View File
@@ -0,0 +1,50 @@
# 故障排查
本文只提供可审计的人工恢复步骤。应用不得把这里的操作包装成自动删除、重命名、隔离或重试按钮。
## 不安全图标缓存(`unsafe_cache`)
### 诊断含义
`unsafe_cache` 表示请求对应的磁盘缓存 entry 是 symlink、目录或其他非普通文件。`IconCache` 会立即 fail closed:不读取或跟随该 entry,不删除、改写或重命名它,也不继续远端获取。本次图标请求保持占位,其他软件列表功能不应被阻塞。
modern 与 Win7 详情只显示以下安全字段:
- 诊断码:`unsafe_cache`。
- 应用 ID:Catalog 的稳定 app ID。
- 缓存定位符:`<digest>-<dpi>.icon`,其中 digest 是已验证 `sha256:` 引用去掉前缀后的 64 位十六进制值,DPI 是 48~768 的已验证整数。
定位符不包含 cache root、原始 URL/query、后台 error、token 或 link target。不得要求用户从 UI 猜测这些内容。
### 确认缓存根
定位符对应的逻辑位置是:
```text
<configured-icon-cache-root>/<digest>-<dpi>.icon
```
安装模式正式装配的目标根是:
```text
%LOCALAPPDATA%\OwnSoftBox\cache\icons\
```
当前仓库尚未在生产 `cmd` 中装配 `NewIconCache`、`IconEventDelivery.LoadAndPublish` 或真实 `IconFetcher`;因此在该装配任务完成前,事实来源始终是调用方传给 `NewIconCache` 的 root,不能仅凭上述目标路径断定实际位置。测试临时目录也不是用户数据目录。
### 人工恢复步骤
1. 完全退出 SoftBox,并用受信任的系统管理工具确认 modern/Win7 客户端及相关后台进程均已退出。不要在应用仍可能访问缓存时处理 entry。
2. 记录诊断码、应用 ID、缓存定位符、发生时间和客户端版本。不要复制 URL/query、凭据或未经验证的 link target。
3. 从部署配置或未来的生产装配记录确认 configured icon cache root。确认它位于预期 OwnSoftBox 数据根内,其父目录可信,且 root 本身不是 symlink、junction 或其他 reparse point。无法确认时立即停止并联系管理员。
4. 只从已核验 root 的父目录侧定位 root 和该 locator。不要打开可疑 entry,不要进入目录,不要解析、跟随或访问其 target;检查应使用不跟随链接的元数据能力。
5. 由管理员按组织的事件响应/文件处置策略处理。可选择从可信父目录侧整体移走已核验的 icon cache 目录,或只处置精确 locator 对应的 entry;任何操作都必须作用于目录项本身且不得递归跟随链接。处理前保留第 2 步的安全诊断信息。
6. 在可信父目录下重新建立空的普通 `icons` 目录后再启动客户端。生产图标装配完成后,后续 cache miss 才会通过已验证 Fetcher 重建普通缓存文件;当前仓库不能宣称重启已经具备该能力。
7. 若相同 locator 再次出现 `unsafe_cache`,立即停止重复处理,保留新的安全诊断并升级给管理员调查 cache root 权限、外部写入者和部署配置。
### 禁止操作
- 不在应用内自动 delete、rename、quarantine 或 retry 可疑 entry。
- 不提供或执行会递归遍历、跟随 reparse point、通配整个用户目录的清理命令。
- 不通过打开 target 来判断其内容,不把绝对路径、target 或原始错误复制到 UI/事件。
- 不把普通损坏文件、离线、hash/decode 失败当成 `unsafe_cache`;这些情况继续使用 `invalid_content` 或 `unavailable`。
+8
View File
@@ -7,3 +7,11 @@
- 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。
- `catalog/manifest-valid-payload.json` 同时作为 manifest v1 强类型解析与目标过滤的公开虚构样例;包哈希与签名只保证格式合法,不对应真实下载物。
- `download/`:T-301 在运行时生成 HTTPS Range、断连、并发和恢复样例,不保存真实下载包。
## Catalog canonicalization vectors
`catalog/canonical-vectors.json` is the versioned, cross-implementation signing corpus for the Catalog signature domain. It contains only a public RFC 8032 test key and fixed synthetic documents, canonical signing bytes and Ed25519 signatures; it never contains a production private key, real Catalog URL or release signature.
Each `document` is the raw UTF-8 JSON text passed to the verifier. A successful vector provides the exact `signed_payload_base64` and top-level `signature`; a rejecting vector provides `want_error` (`invalid_document`, `unsupported_number` or `signature_invalid`). Both the client and the external `softbox-catalog` publisher must consume these static values verbatim. They must not recreate expected bytes or signatures by calling their own canonicalizer or signing helper.
The corpus freezes Unicode key sorting/escaping, U+2028/U+2029, valid and invalid surrogate behavior, `-0` and large integers, nested `signature`, canonical padded Base64 and semantic-equivalent JSON representations. Extending it is a protocol change: increment `schema_version` when its interpretation changes and update `docs/api.md` together with client and publisher tests.
+69
View File
@@ -0,0 +1,69 @@
{
"schema_version": 1,
"public_key_base64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
"vectors": [
{
"name": "unicode-escapes-sorted-keys-and-nested-signature",
"document": "{\"z\":\"\\u003c\\u003e\\u0026\\u2028\\u2029\",\"nested\":{\"signature\":\"inner\",\"n\":9007199254740993},\"emoji\":\"\\uD83D\\uDE00\",\"\\u00e9\":\"\\u00e9\",\"a\":\"line\\u000aquote\\u0022slash\\u005c\",\"signature\":\"JS8or6xTEfGtGOoxPbephpnYbB15fcRL1VnEcoQ0IZIHDx6URuRs3SciDIubEqxVCqy+GVOuyD2lRAOLm11OBQ==\"}",
"signed_payload_base64": "eyJhIjoibGluZVxucXVvdGVcInNsYXNoXFwiLCJlbW9qaSI6IvCfmIAiLCJuZXN0ZWQiOnsibiI6OTAwNzE5OTI1NDc0MDk5Mywic2lnbmF0dXJlIjoiaW5uZXIifSwieiI6Ilx1MDAzY1x1MDAzZVx1MDAyNlx1MjAyOFx1MjAyOSIsIsOpIjoiw6kifQ==",
"signature": "JS8or6xTEfGtGOoxPbephpnYbB15fcRL1VnEcoQ0IZIHDx6URuRs3SciDIubEqxVCqy+GVOuyD2lRAOLm11OBQ=="
},
{
"name": "equivalent-object-order-a",
"document": "{\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\",\"a\":0}",
"signed_payload_base64": "eyJhIjowLCJiIjpbdHJ1ZSxudWxsLCJ4Il19",
"signature": "ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg=="
},
{
"name": "equivalent-object-order-b",
"document": " { \"a\" : 0 , \"b\" : [ true , null , \"x\" ] , \"signature\" : \"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\" } ",
"signed_payload_base64": "eyJhIjowLCJiIjpbdHJ1ZSxudWxsLCJ4Il19",
"signature": "ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg=="
},
{
"name": "reject-isolated-high-surrogate",
"document": "{\"value\":\"\\uD800\",\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}",
"want_error": "invalid_document"
},
{
"name": "reject-isolated-low-surrogate",
"document": "{\"value\":\"\\uDC00\",\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}",
"want_error": "invalid_document"
},
{
"name": "reject-mismatched-surrogate-pair",
"document": "{\"value\":\"\\uD800\\u0041\",\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}",
"want_error": "invalid_document"
},
{
"name": "reject-negative-zero",
"document": "{\"n\":-0,\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}",
"want_error": "unsupported_number"
},
{
"name": "reject-signature-crlf",
"document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59\\r\\nAdULCg==\"}",
"want_error": "signature_invalid"
},
{
"name": "reject-signature-space",
"document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg== \"}",
"want_error": "signature_invalid"
},
{
"name": "reject-signature-tab",
"document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\\t\"}",
"want_error": "signature_invalid"
},
{
"name": "reject-signature-missing-padding",
"document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg\"}",
"want_error": "signature_invalid"
},
{
"name": "reject-signature-extra-padding",
"document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg===\"}",
"want_error": "signature_invalid"
}
]
}