mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524a738eb0 | ||
|
|
2d2450e9a7 | ||
|
|
cabfeefc4e | ||
|
|
0c1de25a04 |
No files matched your search
@@ -91,6 +91,14 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
final-component-only no-follow is insufficient. On multi-step setup, roll back
|
||||
only what this run created; never destroy pre-existing resources you did
|
||||
not create; never report success when cleanup or unlock failed.
|
||||
- **Normalize before matching:** Any transform that REMOVES bytes without
|
||||
leaving a gap is also a reassembler, so it has to run before whatever matches
|
||||
on the result. Redacting by shape and then stripping control bytes lets a
|
||||
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
|
||||
fragments and be rejoined on the way out; comparing a path against a form
|
||||
produced by the same resolver the kernel just used makes a redirect agree with
|
||||
itself. Strip, decode, or canonicalize first, then match. The unsplit value
|
||||
passing is not evidence, so the test needs a split case.
|
||||
- **Atomic shared state:** Write a complete temporary file, then atomically
|
||||
replace the destination so concurrent readers never see a partial write.
|
||||
Exclusive create or a write lock alone is not enough for readers. Serialize
|
||||
|
||||
@@ -70,3 +70,43 @@ Endpoint resolution order:
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
instead of reinstalling.
|
||||
|
||||
## Windows recovery state (standalone installs)
|
||||
|
||||
This section describes Windows only. On Linux and macOS a standalone update
|
||||
renames the staged file directly over the executable path through the
|
||||
installation directory's file descriptor, so the replacement is atomic and no
|
||||
aside copy, marker, or recovery record is ever created — a failed update leaves
|
||||
the previous binary in place and nothing to resolve.
|
||||
|
||||
On Windows, a running executable cannot be replaced in place, so the previous
|
||||
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
|
||||
records that exact file — bound to its filesystem identity, in per-user state
|
||||
outside the installation directory — and deletes only that recorded copy after
|
||||
the next update is verified in place. Backups it did not create are never
|
||||
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
|
||||
recorded copy that something else holds open (an on-access scanner, an editor)
|
||||
stays recorded and is removed by a later update instead.
|
||||
|
||||
An update **refuses to run** while unresolved recovery state exists beside the
|
||||
binary:
|
||||
|
||||
| State on disk | Meaning |
|
||||
|---|---|
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
|
||||
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
|
||||
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
|
||||
|
||||
The refusal names the paths involved and the two moves that end the state:
|
||||
either move the recovery binary back over the executable path, or — if the
|
||||
installed binary is the one you want — delete the `.keep` marker (or the
|
||||
`.recovery` copy, once you have verified the installed binary) and update again.
|
||||
|
||||
This is deliberately fail-closed and differs from older releases, which deleted
|
||||
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
|
||||
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
|
||||
and make every subsequent update refuse until an operator clears them. That is
|
||||
the safe direction: the alternative is an update that overwrites the only
|
||||
verified copy of the previous binary. If updates start refusing, inspect those
|
||||
files before removing them — an installation directory that a lower-privileged
|
||||
account can write to is itself worth fixing.
|
||||
@@ -11,16 +11,17 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sys v0.47.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
|
||||
@@ -64,6 +64,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
|
||||
@@ -429,7 +429,7 @@ func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
}
|
||||
return state
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type contextPlannerConfig struct {
|
||||
contextWindow int
|
||||
promptCacheKey string
|
||||
serviceTier string
|
||||
promptParts systemPromptParts
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []z
|
||||
Messages: requestMessages,
|
||||
Tools: planner.snapshotTools(toolDefs, fingerprint),
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ServiceTier: planner.config.serviceTier,
|
||||
PromptCacheKey: planner.config.promptCacheKey,
|
||||
}
|
||||
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: 128_000,
|
||||
promptCacheKey: "session-1",
|
||||
serviceTier: "priority",
|
||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
||||
})
|
||||
|
||||
@@ -33,6 +34,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: "medium",
|
||||
ServiceTier: "priority",
|
||||
PromptCacheKey: "session-1",
|
||||
}
|
||||
if !reflect.DeepEqual(plan.Request, want) {
|
||||
|
||||
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
var switchedTo string
|
||||
switchCount := 0
|
||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||
switchCount++
|
||||
switchedTo = modelID
|
||||
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
if len(firstProvider.requests) != 1 {
|
||||
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
||||
}
|
||||
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
|
||||
t.Fatalf("initial request service tier = %q, want priority", got)
|
||||
}
|
||||
if len(secondProvider.requests) != 1 {
|
||||
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
||||
}
|
||||
if got := secondProvider.requests[0].ServiceTier; got != "" {
|
||||
t.Fatalf("post-switch request retained service tier %q", got)
|
||||
}
|
||||
if result.FinalAnswer != "answered on the upgraded model" {
|
||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
return nil, errors.New("provider build blew up")
|
||||
},
|
||||
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
if got := provider.requests[1].ServiceTier; got != "priority" {
|
||||
t.Fatalf("failed-switch request service tier = %q, want priority", got)
|
||||
}
|
||||
if result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: options.ContextWindow,
|
||||
promptCacheKey: options.SessionID,
|
||||
serviceTier: options.ServiceTier,
|
||||
promptParts: promptParts,
|
||||
})
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
@@ -829,6 +830,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
_ = session.Prewarm(ctx)
|
||||
provider = sessionProvider{session: session}
|
||||
options.Model = turnRequestedModel
|
||||
// Service tiers are model capabilities. A successful escalation may
|
||||
// target a model that does not support the tier selected before the
|
||||
// switch, so let the destination use its default.
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
|
||||
@@ -325,6 +325,7 @@ type Options struct {
|
||||
ProviderName string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ServiceTier string
|
||||
Cwd string
|
||||
SystemPrompt string
|
||||
// TransientSystemPrompt adds trusted runtime guidance for this run only.
|
||||
|
||||
@@ -842,6 +842,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
SavedPet: resolved.Preferences.Pet,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
@@ -28,46 +23,5 @@ import (
|
||||
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
|
||||
// resolver, and the onboarding presence check never diverge.
|
||||
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
// No login under any candidate (or unreadable/invalid keys) → API-key
|
||||
// auth, no resolver.
|
||||
return nil, ""
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// Refreshing a token the user logged into (possibly a preset provider like
|
||||
// xAI) re-resolves that provider's OAuth config, which needs the preset.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var rerr error
|
||||
if forceRefresh {
|
||||
token, rerr = manager.Handle401(ctx, key)
|
||||
} else {
|
||||
token, rerr = manager.GetFresh(ctx, key)
|
||||
}
|
||||
if errors.Is(rerr, oauth.ErrNoToken) {
|
||||
// The login was removed since construction → fall back to the API key.
|
||||
return "", "", false, nil
|
||||
}
|
||||
if rerr != nil {
|
||||
return "", "", false, rerr
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
return providers.OAuthLoginForProfile(profile)
|
||||
}
|
||||
@@ -73,6 +73,18 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
entry["description"] = description
|
||||
}
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
|
||||
}
|
||||
if effort := strings.TrimSpace(model.DefaultReasoningEffort); effort != "" {
|
||||
entry["default_reasoning_effort"] = effort
|
||||
}
|
||||
if len(model.ServiceTiers) > 0 {
|
||||
entry["service_tiers"] = append([]string{}, model.ServiceTiers...)
|
||||
}
|
||||
if tier := strings.TrimSpace(model.DefaultServiceTier); tier != "" {
|
||||
entry["default_service_tier"] = tier
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
payload := map[string]any{
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type jsonObjectSpan struct {
|
||||
start int
|
||||
end int
|
||||
members []jsonMemberSpan
|
||||
}
|
||||
|
||||
type jsonMemberSpan struct {
|
||||
key string
|
||||
leadingStart int
|
||||
keyStart int
|
||||
valueStart int
|
||||
valueEnd int
|
||||
commaAfter int
|
||||
}
|
||||
|
||||
// setPetPreferenceJSON changes only preferences.pet. It keeps the original
|
||||
// bytes for every unrelated member so choosing a companion does not reorder or
|
||||
// reformat the user's configuration file.
|
||||
func setPetPreferenceJSON(data []byte, pet string) ([]byte, error) {
|
||||
rootStart := skipJSONSpace(data, 0)
|
||||
root, err := parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preferencesIndex := lastJSONMember(root.members, "preferences")
|
||||
if preferencesIndex < 0 {
|
||||
if pet == "" {
|
||||
return data, nil
|
||||
}
|
||||
encodedPet, err := json.Marshal(pet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pet preference: %w", err)
|
||||
}
|
||||
return insertJSONMember(data, root, "preferences", petJSONObject(encodedPet)), nil
|
||||
}
|
||||
|
||||
preferenceMember := root.members[preferencesIndex]
|
||||
preferenceValue := bytes.TrimSpace(data[preferenceMember.valueStart:preferenceMember.valueEnd])
|
||||
if bytes.Equal(preferenceValue, []byte("null")) {
|
||||
if pet == "" {
|
||||
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
|
||||
return data, nil
|
||||
}
|
||||
return removeJSONMember(data, root, preferencesIndex), nil
|
||||
}
|
||||
encodedPet, err := json.Marshal(pet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pet preference: %w", err)
|
||||
}
|
||||
return replaceJSONRange(data, preferenceMember.valueStart, preferenceMember.valueEnd, petJSONObject(encodedPet)), nil
|
||||
}
|
||||
preferences, err := parseJSONObject(data, preferenceMember.valueStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preferences must be a JSON object: %w", err)
|
||||
}
|
||||
petIndex := lastJSONMember(preferences.members, "pet")
|
||||
if pet != "" {
|
||||
encodedPet, err := json.Marshal(pet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pet preference: %w", err)
|
||||
}
|
||||
if petIndex >= 0 {
|
||||
member := preferences.members[petIndex]
|
||||
return replaceJSONRange(data, member.valueStart, member.valueEnd, encodedPet), nil
|
||||
}
|
||||
return insertJSONMember(data, preferences, "pet", encodedPet), nil
|
||||
}
|
||||
|
||||
for petIndex >= 0 {
|
||||
data = removeJSONMember(data, preferences, petIndex)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preferencesIndex = lastJSONMember(root.members, "preferences")
|
||||
if preferencesIndex < 0 {
|
||||
return data, nil
|
||||
}
|
||||
preferenceMember = root.members[preferencesIndex]
|
||||
preferences, err = parseJSONObject(data, preferenceMember.valueStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
petIndex = lastJSONMember(preferences.members, "pet")
|
||||
}
|
||||
if len(preferences.members) == 0 {
|
||||
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
|
||||
return data, nil
|
||||
}
|
||||
return removeJSONMember(data, root, preferencesIndex), nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func parseJSONObject(data []byte, start int) (jsonObjectSpan, error) {
|
||||
if start < 0 || start >= len(data) || data[start] != '{' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected JSON object")
|
||||
}
|
||||
object := jsonObjectSpan{start: start}
|
||||
leadingStart := start + 1
|
||||
for {
|
||||
i := skipJSONSpace(data, leadingStart)
|
||||
if i >= len(data) {
|
||||
return jsonObjectSpan{}, fmt.Errorf("unterminated JSON object")
|
||||
}
|
||||
if data[i] == '}' {
|
||||
object.end = i
|
||||
return object, nil
|
||||
}
|
||||
if data[i] != '"' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected JSON object key")
|
||||
}
|
||||
keyStart := i
|
||||
keyEnd, err := scanJSONString(data, i)
|
||||
if err != nil {
|
||||
return jsonObjectSpan{}, err
|
||||
}
|
||||
var key string
|
||||
if err := json.Unmarshal(data[keyStart:keyEnd], &key); err != nil {
|
||||
return jsonObjectSpan{}, err
|
||||
}
|
||||
i = skipJSONSpace(data, keyEnd)
|
||||
if i >= len(data) || data[i] != ':' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected colon after JSON object key")
|
||||
}
|
||||
valueStart := skipJSONSpace(data, i+1)
|
||||
valueEnd, err := scanJSONValue(data, valueStart)
|
||||
if err != nil {
|
||||
return jsonObjectSpan{}, err
|
||||
}
|
||||
i = skipJSONSpace(data, valueEnd)
|
||||
member := jsonMemberSpan{
|
||||
key: key,
|
||||
leadingStart: leadingStart,
|
||||
keyStart: keyStart,
|
||||
valueStart: valueStart,
|
||||
valueEnd: valueEnd,
|
||||
commaAfter: -1,
|
||||
}
|
||||
if i < len(data) && data[i] == ',' {
|
||||
member.commaAfter = i
|
||||
object.members = append(object.members, member)
|
||||
leadingStart = i + 1
|
||||
continue
|
||||
}
|
||||
if i >= len(data) || data[i] != '}' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected comma or end of JSON object")
|
||||
}
|
||||
object.members = append(object.members, member)
|
||||
object.end = i
|
||||
return object, nil
|
||||
}
|
||||
}
|
||||
|
||||
func scanJSONString(data []byte, start int) (int, error) {
|
||||
for i := start + 1; i < len(data); i++ {
|
||||
switch data[i] {
|
||||
case '\\':
|
||||
i++
|
||||
case '"':
|
||||
return i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unterminated JSON string")
|
||||
}
|
||||
|
||||
func scanJSONValue(data []byte, start int) (int, error) {
|
||||
if start >= len(data) {
|
||||
return 0, fmt.Errorf("missing JSON value")
|
||||
}
|
||||
if data[start] == '"' {
|
||||
return scanJSONString(data, start)
|
||||
}
|
||||
if data[start] == '{' || data[start] == '[' {
|
||||
stack := []byte{data[start]}
|
||||
for i := start + 1; i < len(data); i++ {
|
||||
if data[i] == '"' {
|
||||
end, err := scanJSONString(data, i)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
switch data[i] {
|
||||
case '{', '[':
|
||||
stack = append(stack, data[i])
|
||||
case '}', ']':
|
||||
open := stack[len(stack)-1]
|
||||
if (open == '{' && data[i] != '}') || (open == '[' && data[i] != ']') {
|
||||
return 0, fmt.Errorf("mismatched JSON delimiter")
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
if len(stack) == 0 {
|
||||
return i + 1, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unterminated JSON value")
|
||||
}
|
||||
for i := start; i < len(data); i++ {
|
||||
switch data[i] {
|
||||
case ',', '}', ']', ' ', '\t', '\r', '\n':
|
||||
if i == start {
|
||||
return 0, fmt.Errorf("missing JSON value")
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func skipJSONSpace(data []byte, start int) int {
|
||||
for start < len(data) {
|
||||
switch data[start] {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
start++
|
||||
default:
|
||||
return start
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func lastJSONMember(members []jsonMemberSpan, key string) int {
|
||||
for i := len(members) - 1; i >= 0; i-- {
|
||||
if members[i].key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func petJSONObject(encodedPet []byte) []byte {
|
||||
result := append([]byte(`{"pet":`), encodedPet...)
|
||||
return append(result, '}')
|
||||
}
|
||||
|
||||
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
|
||||
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
|
||||
result = append(result, data[:start]...)
|
||||
result = append(result, replacement...)
|
||||
return append(result, data[end:]...)
|
||||
}
|
||||
|
||||
func removeJSONMember(data []byte, object jsonObjectSpan, index int) []byte {
|
||||
member := object.members[index]
|
||||
start, end := member.leadingStart, member.valueEnd
|
||||
if member.commaAfter >= 0 {
|
||||
end = member.commaAfter + 1
|
||||
} else if index > 0 {
|
||||
start = object.members[index-1].commaAfter
|
||||
}
|
||||
return replaceJSONRange(data, start, end, nil)
|
||||
}
|
||||
|
||||
func insertJSONMember(data []byte, object jsonObjectSpan, key string, value []byte) []byte {
|
||||
encodedKey, _ := json.Marshal(key)
|
||||
entry := append(append(encodedKey, ':', ' '), value...)
|
||||
if len(object.members) == 0 {
|
||||
interior := data[object.start+1 : object.end]
|
||||
if newline := bytes.LastIndexByte(interior, '\n'); newline >= 0 {
|
||||
closingIndent := interior[newline+1:]
|
||||
indentUnit := []byte(" ")
|
||||
if bytes.Contains(closingIndent, []byte("\t")) {
|
||||
indentUnit = []byte("\t")
|
||||
}
|
||||
lineBreak := []byte("\n")
|
||||
if newline > 0 && interior[newline-1] == '\r' {
|
||||
lineBreak = []byte("\r\n")
|
||||
}
|
||||
insert := append(append(append(lineBreak, closingIndent...), indentUnit...), entry...)
|
||||
return replaceJSONRange(data, object.start+1, object.start+1, insert)
|
||||
}
|
||||
return replaceJSONRange(data, object.end, object.end, entry)
|
||||
}
|
||||
last := object.members[len(object.members)-1]
|
||||
separator := []byte(", ")
|
||||
if bytes.Contains(data[last.valueEnd:object.end], []byte("\n")) {
|
||||
lineStart := bytes.LastIndexByte(data[:last.keyStart], '\n') + 1
|
||||
indent := data[lineStart:last.keyStart]
|
||||
separator = append([]byte(",\n"), indent...)
|
||||
}
|
||||
insert := append(separator, entry...)
|
||||
return replaceJSONRange(data, last.valueEnd, last.valueEnd, insert)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetPetPreservesUnrelatedConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := FileConfig{
|
||||
ActiveProvider: "test",
|
||||
Providers: []ProviderProfile{{Name: "test", Model: "example"}},
|
||||
Preferences: PreferencesConfig{Theme: "dracula"},
|
||||
}
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := SetPet(path, "boba")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Preferences.Pet != "boba" || updated.Preferences.Theme != "dracula" || updated.ActiveProvider != "test" || len(updated.Providers) != 1 {
|
||||
t.Fatalf("SetPet lost config fields: %#v", updated)
|
||||
}
|
||||
reloadedData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reloaded FileConfig
|
||||
if err := json.Unmarshal(reloadedData, &reloaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Preferences.Pet != "boba" || reloaded.Preferences.Theme != "dracula" || reloaded.ActiveProvider != "test" || len(reloaded.Providers) != 1 {
|
||||
t.Fatalf("persisted config lost fields: %#v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetRejectsInvalidInputs(t *testing.T) {
|
||||
if _, err := SetPet(" ", "boba"); err == nil {
|
||||
t.Fatal("SetPet accepted a blank config path")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"preferences":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, "boba"); err == nil || !strings.Contains(err.Error(), "invalid config JSON") {
|
||||
t.Fatalf("SetPet malformed JSON error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetCreatesNestedConfigAndClearsEmptyPreferences(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "zero", "config.json")
|
||||
if _, err := SetPet(path, " boba "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var preferences struct {
|
||||
Pet string `json:"pet"`
|
||||
}
|
||||
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preferences.Pet != "boba" {
|
||||
t.Fatalf("persisted pet = %q, want %q", preferences.Pet, "boba")
|
||||
}
|
||||
root["future"] = json.RawMessage(`{"enabled":true}`)
|
||||
encoded, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = nil
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := root["preferences"]; ok {
|
||||
t.Fatalf("empty preferences were retained: %s", data)
|
||||
}
|
||||
var future struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(root["future"], &future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !future.Enabled {
|
||||
t.Fatalf("unrelated future member was lost: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetPreservesUnknownJSONMembers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := []byte(`{
|
||||
"activeProvider": "test",
|
||||
"futureTopLevel": {"enabled": true},
|
||||
"preferences": {
|
||||
"theme": "dracula",
|
||||
"futurePreference": {"mode": "extended"}
|
||||
}
|
||||
}`)
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, "boba"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futureTopLevel struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(root["futureTopLevel"], &futureTopLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !futureTopLevel.Enabled {
|
||||
t.Fatalf("unknown top-level member = %s", root["futureTopLevel"])
|
||||
}
|
||||
var preferences map[string]json.RawMessage
|
||||
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futurePreference struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.Unmarshal(preferences["futurePreference"], &futurePreference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if futurePreference.Mode != "extended" {
|
||||
t.Fatalf("unknown preference member = %s", preferences["futurePreference"])
|
||||
}
|
||||
if got := string(preferences["pet"]); got != `"boba"` {
|
||||
t.Fatalf("pet preference = %s, want boba", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetPreservesExistingJSONLayoutAndMemberOrder(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := []byte("{\n\t\"zFuture\": {\"enabled\":true},\n\t\"preferences\": {\n\t\t\"theme\" : \"dracula\",\n\t\t\"pet\" : \"old\",\n\t\t\"futurePreference\": {\"mode\":\"extended\"}\n\t},\n\t\"activeProvider\": \"test\"\n}\n")
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, "boba"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(string(original), `"old"`, `"boba"`, 1)
|
||||
if string(got) != want {
|
||||
t.Fatalf("SetPet reformatted or reordered config:\n--- got ---\n%s--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetMinimallyEditsExistingJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
original string
|
||||
pet string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "add pet to existing preferences",
|
||||
original: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "boba",
|
||||
want: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "add preferences without reordering root",
|
||||
original: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "boba",
|
||||
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\",\n \"preferences\": {\"pet\":\"boba\"}\n}\n",
|
||||
},
|
||||
{
|
||||
name: "remove pet but retain preferences",
|
||||
original: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
|
||||
pet: "",
|
||||
want: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
|
||||
},
|
||||
{
|
||||
name: "remove empty preferences member",
|
||||
original: "{\n \"zFuture\": true,\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "",
|
||||
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "do not expose an older duplicate preference",
|
||||
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{\"pet\":\"dog\"}}\n",
|
||||
pet: "",
|
||||
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{}}\n",
|
||||
},
|
||||
{
|
||||
name: "do not expose an older duplicate behind null",
|
||||
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
|
||||
pet: "",
|
||||
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
|
||||
},
|
||||
{
|
||||
name: "preserve multiline empty preferences layout",
|
||||
original: "{\n \"preferences\": {\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "boba",
|
||||
want: "{\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(test.original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, test.pet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != test.want {
|
||||
t.Fatalf("SetPet output:\n--- got ---\n%s--- want ---\n%s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if strings.TrimSpace(src.Preferences.Theme) != "" {
|
||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||
}
|
||||
if strings.TrimSpace(src.Preferences.Pet) != "" {
|
||||
dst.Preferences.Pet = strings.TrimSpace(src.Preferences.Pet)
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
mergeSTTConfig(&dst.STT, src.STT)
|
||||
|
||||
@@ -168,6 +168,22 @@ func TestResolveLoadsRecentModelsFromUserConfigOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoadsPetFromUserConfigOnly(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"preferences":{"pet":" boba "}}`)
|
||||
projectPath := writeConfig(t, `{"preferences":{"pet":"project-pet"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Preferences.Pet != "boba" {
|
||||
t.Fatalf("Preferences.Pet = %q, want user preference boba", resolved.Preferences.Pet)
|
||||
}
|
||||
}
|
||||
|
||||
// recentModels must never be merged from project config (same posture as
|
||||
// favoriteModels): a project setting it with no corresponding user setting
|
||||
// should leave the resolved value empty, not pick up the project's list.
|
||||
|
||||
@@ -112,6 +112,9 @@ type PreferencesConfig struct {
|
||||
// name (e.g. "dracula"). Applied at startup below the --theme flag and
|
||||
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// Pet is the terminal companion selected through /pets. Empty leaves pets
|
||||
// off until the user chooses one; "disabled" is the explicit off state.
|
||||
Pet string `json:"pet,omitempty"`
|
||||
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
|
||||
// the user turned idle recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
|
||||
@@ -591,6 +591,35 @@ func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
data := []byte("{}")
|
||||
if existing, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(existing, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
data = existing
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
pet = strings.TrimSpace(pet)
|
||||
cfg.Preferences.Pet = pet
|
||||
data, err := setPetPreferenceJSON(data, pet)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
if err := writeConfigData(path, data); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTModel persists the dictation model and its provider, mirroring
|
||||
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
|
||||
// provider kinds; a local provider stores the model as stt.localModelPath,
|
||||
@@ -736,17 +765,23 @@ func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry {
|
||||
}
|
||||
|
||||
func writeConfigFile(path string, cfg FileConfig) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode config JSON: %w", err)
|
||||
}
|
||||
return writeConfigData(path, data)
|
||||
}
|
||||
|
||||
func writeConfigData(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode config JSON: %w", err)
|
||||
if len(data) == 0 || data[len(data)-1] != '\n' {
|
||||
data = append(data, '\n')
|
||||
}
|
||||
data = append(data, '\n')
|
||||
// Write-to-temp + rename: an in-place write interrupted mid-way (crash,
|
||||
// disk full) would leave the user's only config truncated or corrupt.
|
||||
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
|
||||
|
||||
@@ -29,6 +29,9 @@ const (
|
||||
// newer Anthropic models). Defined so a capability catalog that lists "max"
|
||||
// has a constant to map to; no curated model lists it yet.
|
||||
ReasoningEffortMax ReasoningEffort = "max"
|
||||
// ReasoningEffortUltra is advertised by models whose provider exposes an
|
||||
// additional tier above max.
|
||||
ReasoningEffortUltra ReasoningEffort = "ultra"
|
||||
)
|
||||
|
||||
type ModelCapability string
|
||||
@@ -410,7 +413,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
|
||||
|
||||
func ValidReasoningEffort(effort ReasoningEffort) bool {
|
||||
switch effort {
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax, ReasoningEffortUltra:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
ReasoningEfforts []string
|
||||
DefaultReasoningEffort string
|
||||
ServiceTiers []string
|
||||
DefaultServiceTier string
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
}
|
||||
|
||||
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
|
||||
@@ -270,6 +274,8 @@ func dedupeModels(defaultModel string, models []Model) []Model {
|
||||
}
|
||||
model.InputModalities = append([]string{}, model.InputModalities...)
|
||||
model.OutputModalities = append([]string{}, model.OutputModalities...)
|
||||
model.ReasoningEfforts = append([]string{}, model.ReasoningEfforts...)
|
||||
model.ServiceTiers = append([]string{}, model.ServiceTiers...)
|
||||
model.Tags = append([]string{}, model.Tags...)
|
||||
seen[model.ID] = true
|
||||
result = append(result, model)
|
||||
|
||||
@@ -122,6 +122,25 @@ func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeModelsDoesNotAliasCapabilitySlices(t *testing.T) {
|
||||
models := []Model{{
|
||||
ID: "reasoner",
|
||||
ReasoningEfforts: []string{"low", "high"},
|
||||
ServiceTiers: []string{"priority"},
|
||||
}}
|
||||
first := dedupeModels("reasoner", models)
|
||||
first[0].ReasoningEfforts[0] = "mutated"
|
||||
first[0].ServiceTiers[0] = "mutated"
|
||||
|
||||
second := dedupeModels("reasoner", models)
|
||||
if got, want := second[0].ReasoningEfforts[0], "low"; got != want {
|
||||
t.Fatalf("reasoning efforts alias source state: got %q, want %q", got, want)
|
||||
}
|
||||
if got, want := second[0].ServiceTiers[0], "priority"; got != want {
|
||||
t.Fatalf("service tiers alias source state: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func modelIDs(models []Model) []string {
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
|
||||
@@ -230,6 +230,11 @@ type remoteModel struct {
|
||||
Tools bool `json:"tools"`
|
||||
// ReasoningRaw accepts both a boolean (models.dev) and OpenRouter's object form.
|
||||
ReasoningRaw json.RawMessage `json:"reasoning"`
|
||||
ReasoningEfforts []string `json:"reasoning_efforts"`
|
||||
DefaultReasoning string `json:"default_reasoning_effort"`
|
||||
ServiceTiers []string `json:"service_tiers"`
|
||||
AdditionalSpeed []string `json:"additional_speed_tiers"`
|
||||
DefaultServiceTier string `json:"default_service_tier"`
|
||||
Free bool `json:"free"`
|
||||
IsFree bool `json:"is_free"`
|
||||
InputCost float64 `json:"input_cost"`
|
||||
@@ -309,6 +314,8 @@ func (model remoteModel) toModel(key string, source string) Model {
|
||||
containsFold(model.SupportedParameters, "reasoning") ||
|
||||
containsFold(model.SupportedParameters, "reasoning_effort") ||
|
||||
containsFold(model.SupportedParameters, "include_reasoning")
|
||||
reasoningEfforts := model.supportedReasoningEfforts()
|
||||
serviceTiers := normalizedServiceTiers(model.ServiceTiers, model.AdditionalSpeed)
|
||||
|
||||
// Prefer the short display name for aggregator catalogs (OpenRouter's
|
||||
// description field is multi-sentence marketing copy). models.dev and
|
||||
@@ -319,20 +326,56 @@ func (model remoteModel) toModel(key string, source string) Model {
|
||||
}
|
||||
|
||||
return Model{
|
||||
ID: strings.TrimSpace(id),
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning,
|
||||
InputModalities: inputModalities,
|
||||
OutputModalities: outputModalities,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
Tags: cleanStrings(model.Tags),
|
||||
Source: source,
|
||||
ID: strings.TrimSpace(id),
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning,
|
||||
ReasoningEfforts: reasoningEfforts,
|
||||
DefaultReasoningEffort: strings.TrimSpace(model.DefaultReasoning),
|
||||
ServiceTiers: serviceTiers,
|
||||
DefaultServiceTier: normalizeServiceTier(model.DefaultServiceTier),
|
||||
InputModalities: inputModalities,
|
||||
OutputModalities: outputModalities,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
Tags: cleanStrings(model.Tags),
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedServiceTiers(tiers []string, legacy []string) []string {
|
||||
values := append(append([]string{}, tiers...), legacy...)
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = normalizeServiceTier(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeServiceTier(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "fast" {
|
||||
return "priority"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (model remoteModel) supportedReasoningEfforts() []string {
|
||||
efforts := append([]string{}, model.ReasoningEfforts...)
|
||||
var info remoteReasoningInfo
|
||||
if err := json.Unmarshal(bytes.TrimSpace(model.ReasoningRaw), &info); err == nil {
|
||||
efforts = append(efforts, info.Supported...)
|
||||
}
|
||||
return cleanStrings(efforts)
|
||||
}
|
||||
|
||||
func (model remoteModel) supportsReasoning() bool {
|
||||
raw := bytes.TrimSpace(model.ReasoningRaw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
|
||||
@@ -19,20 +19,27 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
const anthropicVersion = "2023-06-01"
|
||||
const (
|
||||
anthropicVersion = "2023-06-01"
|
||||
chatGPTModelsProtocolVersion = "0.146.0"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
ReasoningEfforts []string
|
||||
DefaultReasoningEffort string
|
||||
ServiceTiers []string
|
||||
DefaultServiceTier string
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -43,6 +50,7 @@ type Options struct {
|
||||
OAuthResolver providerio.TokenResolver
|
||||
CodexAccountResolver openai.CodexAccountResolver
|
||||
UserAgent string
|
||||
ClientVersion string
|
||||
}
|
||||
|
||||
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
|
||||
@@ -51,6 +59,7 @@ func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, p
|
||||
// without credentials so the picker stays current before a key is entered.
|
||||
canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth ||
|
||||
discoveryHasCredential(profile) ||
|
||||
options.OAuthResolver != nil ||
|
||||
publicLiveCatalogProvider(provider, profile))
|
||||
if canProbeProvider {
|
||||
liveModels, liveErr := Discover(ctx, profile, options)
|
||||
@@ -204,6 +213,18 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
|
||||
}
|
||||
var configure func(*http.Request)
|
||||
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
|
||||
parsed, parseErr := url.Parse(endpoint)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse ChatGPT models endpoint: %w", parseErr)
|
||||
}
|
||||
clientVersion := strings.TrimSpace(options.ClientVersion)
|
||||
if clientVersion == "" {
|
||||
clientVersion = chatGPTModelsProtocolVersion
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("client_version", clientVersion)
|
||||
parsed.RawQuery = query.Encode()
|
||||
endpoint = parsed.String()
|
||||
configure = func(request *http.Request) {
|
||||
account := ""
|
||||
if options.CodexAccountResolver != nil {
|
||||
@@ -326,24 +347,39 @@ func anthropicModelsEndpoint(baseURL string) (string, error) {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
type modelsResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ContextWindowAlt int `json:"contextWindow"`
|
||||
ContextLength int `json:"context_length"`
|
||||
MaxContextLength int `json:"max_context_length"`
|
||||
Free bool `json:"free"`
|
||||
IsFree bool `json:"is_free"`
|
||||
ToolCall bool `json:"tool_call"`
|
||||
ToolCallCamel bool `json:"toolCall"`
|
||||
Tools bool `json:"tools"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
ReasoningEfforts []string `json:"reasoning_efforts"`
|
||||
DefaultReasoning string `json:"default_reasoning_level"`
|
||||
SupportedReasoning []struct {
|
||||
Effort string `json:"effort"`
|
||||
} `json:"supported_reasoning_levels"`
|
||||
ServiceTiers []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"service_tiers"`
|
||||
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
|
||||
DefaultServiceTier string `json:"default_service_tier"`
|
||||
SupportedParameters []string `json:"supported_parameters"`
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ContextWindowAlt int `json:"contextWindow"`
|
||||
ContextLength int `json:"context_length"`
|
||||
MaxContextLength int `json:"max_context_length"`
|
||||
Free bool `json:"free"`
|
||||
IsFree bool `json:"is_free"`
|
||||
ToolCall bool `json:"tool_call"`
|
||||
ToolCallCamel bool `json:"toolCall"`
|
||||
Tools bool `json:"tools"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
SupportedParameters []string `json:"supported_parameters"`
|
||||
} `json:"data"`
|
||||
Data []modelsResponseItem `json:"data"`
|
||||
Models []modelsResponseItem `json:"models"`
|
||||
}
|
||||
|
||||
func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
@@ -352,12 +388,21 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
return nil, fmt.Errorf("decode models response: %w", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
models := make([]Model, 0, len(payload.Data))
|
||||
for _, item := range payload.Data {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
items := payload.Data
|
||||
if len(items) == 0 {
|
||||
items = payload.Models
|
||||
}
|
||||
models := make([]Model, 0, len(items))
|
||||
for _, item := range items {
|
||||
id := firstNonEmptyDiscovery(item.ID, item.Slug)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
// The ChatGPT catalog includes hidden/internal entries alongside picker
|
||||
// models. Generic OpenAI-compatible responses omit visibility entirely.
|
||||
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
description := firstNonEmptyDiscovery(
|
||||
item.DisplayName,
|
||||
@@ -381,13 +426,19 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
discoveryContainsFold(item.SupportedParameters, "reasoning") ||
|
||||
discoveryContainsFold(item.SupportedParameters, "reasoning_effort") ||
|
||||
discoveryContainsFold(item.SupportedParameters, "include_reasoning")
|
||||
efforts := reasoningEfforts(item)
|
||||
serviceTiers := modelServiceTiers(item)
|
||||
models = append(models, Model{
|
||||
ID: id,
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning,
|
||||
Source: "live",
|
||||
ID: id,
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning || len(efforts) > 0,
|
||||
ReasoningEfforts: efforts,
|
||||
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
|
||||
ServiceTiers: serviceTiers,
|
||||
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
|
||||
Source: "live",
|
||||
})
|
||||
}
|
||||
sort.SliceStable(models, func(i, j int) bool {
|
||||
@@ -399,6 +450,50 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func modelServiceTiers(item modelsResponseItem) []string {
|
||||
values := append([]string{}, item.AdditionalSpeedTiers...)
|
||||
for _, tier := range item.ServiceTiers {
|
||||
values = append(values, tier.ID)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = normalizeServiceTier(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeServiceTier(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "fast" {
|
||||
return "priority"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reasoningEfforts(item modelsResponseItem) []string {
|
||||
efforts := append([]string{}, item.ReasoningEfforts...)
|
||||
for _, level := range item.SupportedReasoning {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(efforts))
|
||||
for _, effort := range efforts {
|
||||
effort = strings.ToLower(strings.TrimSpace(effort))
|
||||
if effort == "" || seen[effort] {
|
||||
continue
|
||||
}
|
||||
seen[effort] = true
|
||||
result = append(result, effort)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchCatalogModels(ctx context.Context, provider providercatalog.Descriptor, options Options) ([]Model, error) {
|
||||
models, err := providermodelcatalog.FetchRemote(ctx, provider, providermodelcatalog.FetchOptions{
|
||||
HTTPClient: options.HTTPClient,
|
||||
@@ -416,17 +511,21 @@ func modelsFromCatalog(models []providermodelcatalog.Model) []Model {
|
||||
result := make([]Model, 0, len(models))
|
||||
for _, model := range models {
|
||||
result = append(result, Model{
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
|
||||
DefaultReasoningEffort: model.DefaultReasoningEffort,
|
||||
ServiceTiers: append([]string{}, model.ServiceTiers...),
|
||||
DefaultServiceTier: model.DefaultServiceTier,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -440,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
hasCatalog := len(byID) > 0
|
||||
// Aggregators publish the live list as the source of truth. Keep live-only
|
||||
// ids even when a remote catalog also loaded, instead of intersecting.
|
||||
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID)
|
||||
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
|
||||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
|
||||
result := make([]Model, 0, len(liveModels))
|
||||
for _, live := range liveModels {
|
||||
if catalog, ok := byID[live.ID]; ok {
|
||||
@@ -458,6 +558,21 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
if strings.TrimSpace(catalog.Description) == "" && strings.TrimSpace(live.Description) != "" {
|
||||
catalog.Description = live.Description
|
||||
}
|
||||
if len(live.ReasoningEfforts) > 0 {
|
||||
catalog.ReasoningEfforts = append([]string{}, live.ReasoningEfforts...)
|
||||
}
|
||||
if live.DefaultReasoningEffort != "" {
|
||||
catalog.DefaultReasoningEffort = live.DefaultReasoningEffort
|
||||
}
|
||||
if live.Reasoning || len(live.ReasoningEfforts) > 0 || live.DefaultReasoningEffort != "" {
|
||||
catalog.Reasoning = true
|
||||
}
|
||||
if len(live.ServiceTiers) > 0 {
|
||||
catalog.ServiceTiers = append([]string{}, live.ServiceTiers...)
|
||||
}
|
||||
if live.DefaultServiceTier != "" {
|
||||
catalog.DefaultServiceTier = live.DefaultServiceTier
|
||||
}
|
||||
catalog.Source = firstDiscoverySource(catalog.Source, "live")
|
||||
result = append(result, catalog)
|
||||
continue
|
||||
@@ -526,17 +641,21 @@ func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id stri
|
||||
|
||||
func catalogModelFromDiscovery(model Model) providermodelcatalog.Model {
|
||||
return providermodelcatalog.Model{
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
|
||||
DefaultReasoningEffort: model.DefaultReasoningEffort,
|
||||
ServiceTiers: append([]string{}, model.ServiceTiers...),
|
||||
DefaultServiceTier: model.DefaultServiceTier,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,90 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseSupportsChatGPTCatalog(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{
|
||||
"models": [
|
||||
{"slug":"gpt-5.6-sol","display_name":"GPT-5.6 Sol","description":"Frontier coding model","visibility":"list","context_window":400000,"default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"service_tiers":[{"id":"priority"}],"default_service_tier":"standard"},
|
||||
{"slug":"gpt-5.6-terra","display_name":"GPT-5.6 Terra","visibility":"list"},
|
||||
{"slug":"internal-router","display_name":"Internal","visibility":"hide"},
|
||||
{"slug":"server-only","display_name":"Server only","visibility":"none"}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelsResponse: %v", err)
|
||||
}
|
||||
if got, want := modelIDs(models), []string{"gpt-5.6-sol", "gpt-5.6-terra"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("models = %#v, want visible ChatGPT models %#v", got, want)
|
||||
}
|
||||
if models[0].Description != "GPT-5.6 Sol" || models[0].ContextWindow != 400000 {
|
||||
t.Fatalf("ChatGPT metadata = %#v", models[0])
|
||||
}
|
||||
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,medium,high,xhigh" {
|
||||
t.Fatalf("reasoning efforts = %q", got)
|
||||
}
|
||||
if models[0].DefaultReasoningEffort != "high" || !models[0].Reasoning {
|
||||
t.Fatalf("reasoning metadata = %#v", models[0])
|
||||
}
|
||||
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" || models[0].DefaultServiceTier != "standard" {
|
||||
t.Fatalf("service tier metadata = %#v", models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseNormalizesLegacyFastTier(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{"data":[{"id":"gpt-test","additional_speed_tiers":["fast","priority"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelsResponse: %v", err)
|
||||
}
|
||||
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
|
||||
t.Fatalf("service tiers = %q, want priority", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
[]Model{
|
||||
{ID: "gpt-5.5", ReasoningEfforts: []string{"low", "high"}, ServiceTiers: []string{"priority"}},
|
||||
{ID: "gpt-5.6-sol", ReasoningEfforts: []string{"low", "ultra"}},
|
||||
},
|
||||
[]Model{{ID: "gpt-5.5", Description: "fallback"}},
|
||||
)
|
||||
if got, want := strings.Join(modelIDs(models), ","), "gpt-5.5,gpt-5.6-sol"; got != want {
|
||||
t.Fatalf("models = %q, want %q", got, want)
|
||||
}
|
||||
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,high" {
|
||||
t.Fatalf("merged reasoning efforts = %q", got)
|
||||
}
|
||||
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
|
||||
t.Fatalf("merged service tiers = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
[]Model{{
|
||||
ID: "gpt-live",
|
||||
Reasoning: true,
|
||||
DefaultReasoningEffort: "high",
|
||||
DefaultServiceTier: "priority",
|
||||
}},
|
||||
[]Model{{
|
||||
ID: "gpt-live",
|
||||
Reasoning: false,
|
||||
DefaultReasoningEffort: "low",
|
||||
DefaultServiceTier: "standard",
|
||||
}},
|
||||
)
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("merged models = %#v, want one model", models)
|
||||
}
|
||||
got := models[0]
|
||||
if !got.Reasoning || got.DefaultReasoningEffort != "high" || got.DefaultServiceTier != "priority" {
|
||||
t.Fatalf("merged live metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
|
||||
// Catalog and live endpoints return distinct payloads so the merge must keep
|
||||
// live-only ids that are absent from the remote catalog response.
|
||||
@@ -206,6 +290,9 @@ func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
|
||||
if got := r.URL.Path; got != "/backend-api/codex/models" {
|
||||
t.Errorf("path = %q, want Codex models endpoint", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("client_version"); got != chatGPTModelsProtocolVersion {
|
||||
t.Errorf("client_version = %q, want %q", got, chatGPTModelsProtocolVersion)
|
||||
}
|
||||
wantToken := "Bearer old-token"
|
||||
wantAccount := "old-account"
|
||||
if requests == 2 {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// OAuthLoginForProfile resolves the OAuth login used by profile once and
|
||||
// returns both its bearer resolver and credential-store key. Keeping the key
|
||||
// alongside the resolver lets auxiliary requests derive account-scoped headers
|
||||
// from the same login that supplies the bearer.
|
||||
func OAuthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
return nil, ""
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var resolveErr error
|
||||
if forceRefresh {
|
||||
token, resolveErr = manager.Handle401(ctx, key)
|
||||
} else {
|
||||
token, resolveErr = manager.GetFresh(ctx, key)
|
||||
}
|
||||
if errors.Is(resolveErr, oauth.ErrNoToken) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if resolveErr != nil {
|
||||
return "", "", false, resolveErr
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
func TestOAuthLoginForProfileBindsBearerAndAccountToSameLogin(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
key := oauth.ProviderKey("chatgpt")
|
||||
if err := store.Save(key, oauth.Token{
|
||||
AccessToken: "subscription-token",
|
||||
Account: "account-42",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("save OAuth token: %v", err)
|
||||
}
|
||||
|
||||
resolver, loginKey := OAuthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
|
||||
if resolver == nil || loginKey != key {
|
||||
t.Fatalf("OAuth login = (%v, %q), want resolver bound to %q", resolver != nil, loginKey, key)
|
||||
}
|
||||
header, value, ok, err := resolver(context.Background(), false)
|
||||
if err != nil || !ok || header != "Authorization" || value != "Bearer subscription-token" {
|
||||
t.Fatalf("bearer resolution = (%q, %q, %v, %v)", header, value, ok, err)
|
||||
}
|
||||
account, ok, err := CodexAccountResolverForLogin(loginKey)(context.Background())
|
||||
if err != nil || !ok || account != "account-42" {
|
||||
t.Fatalf("account resolution = (%q, %v, %v)", account, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginForProfileReturnsNoResolverWithoutUsableLogin(t *testing.T) {
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(t.TempDir(), "oauth-tokens.json"))
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
}{
|
||||
{
|
||||
name: "profile with its own credential has no OAuth candidates",
|
||||
profile: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKey: "sk-configured"},
|
||||
},
|
||||
{
|
||||
name: "candidate has no stored login",
|
||||
profile: config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resolver, key := OAuthLoginForProfile(test.profile)
|
||||
if resolver != nil || key != "" {
|
||||
t.Fatalf("OAuthLoginForProfile() = (%v, %q), want (nil, empty)", resolver != nil, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ type responsesRequest struct {
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Tools []responsesTool `json:"tools,omitempty"`
|
||||
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
// responsesReasoning carries the reasoning controls for the Responses API. The
|
||||
@@ -265,6 +266,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
// long thinking phase shows live progress instead of looking hung.
|
||||
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
|
||||
}
|
||||
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
|
||||
req.ServiceTier = tier
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -492,6 +492,49 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderNormalizesServiceTier(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "priority", input: "priority", want: "priority"},
|
||||
{name: "flex", input: "flex", want: "flex"},
|
||||
{name: "unsupported omitted", input: "standard", want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ServiceTier: test.input,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drainCodexEvents(t, stream)
|
||||
if test.want == "" {
|
||||
if _, ok := rec.body["service_tier"]; ok {
|
||||
t.Fatalf("body.service_tier = %#v, want omitted", rec.body["service_tier"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := rec.body["service_tier"]; got != test.want {
|
||||
t.Fatalf("body.service_tier = %#v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
|
||||
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
|
||||
// "thinking"), in order, alongside the normal text output. Without this a long
|
||||
|
||||
@@ -467,6 +467,9 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
mapped.ReasoningEffort = effort
|
||||
}
|
||||
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
|
||||
mapped.ServiceTier = tier
|
||||
}
|
||||
// prompt_cache_key is a documented OpenAI parameter for server-side prefix
|
||||
// cache routing. Official OpenAI accepts it; many openai-compatible
|
||||
// gateways (NVIDIA NIM, strict local proxies) reject unknown fields with a
|
||||
@@ -505,7 +508,16 @@ func promptCacheKeyDisabled() bool {
|
||||
// is dropped rather than risking a 400 on an unrecognized enum.
|
||||
func openAIReasoningEffort(requested string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(requested)) {
|
||||
case "minimal", "low", "medium", "high":
|
||||
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
|
||||
return strings.ToLower(strings.TrimSpace(requested))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIServiceTier(requested string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(requested)) {
|
||||
case "priority", "flex":
|
||||
return strings.ToLower(strings.TrimSpace(requested))
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -860,6 +860,51 @@ func TestStreamCompletionSendsReasoningEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionNormalizesServiceTier(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "priority", input: "priority", want: "priority"},
|
||||
{name: "flex with surrounding whitespace", input: " FlEx ", want: "flex"},
|
||||
{name: "unsupported omitted", input: "standard", want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `[DONE]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL + "/", Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ServiceTier: test.input,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
if test.want == "" {
|
||||
if _, ok := gotBody["service_tier"]; ok {
|
||||
t.Fatalf("service_tier = %#v, want omitted", gotBody["service_tier"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := gotBody["service_tier"]; got != test.want {
|
||||
t.Fatalf("service_tier = %#v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionOmitsReasoningEffortWhenUnsetOrInvalid(t *testing.T) {
|
||||
for _, effort := range []string{"", "none", "bogus"} {
|
||||
var gotBody map[string]any
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package openai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOpenAIReasoningEffortPreservesKnownProviderTiers(t *testing.T) {
|
||||
for _, effort := range []string{"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} {
|
||||
if got := openAIReasoningEffort(effort); got != effort {
|
||||
t.Fatalf("openAIReasoningEffort(%q) = %q", effort, got)
|
||||
}
|
||||
}
|
||||
if got := openAIReasoningEffort("unknown"); got != "" {
|
||||
t.Fatalf("unknown effort = %q, want omitted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIServiceTierNormalizesKnownValues(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "priority", want: "priority"},
|
||||
{input: "flex", want: "flex"},
|
||||
{input: " PRIORITY ", want: "priority"},
|
||||
{input: "FlEx", want: "flex"},
|
||||
{input: "", want: ""},
|
||||
{input: "standard", want: ""},
|
||||
} {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
if got := openAIServiceTier(test.input); got != test.want {
|
||||
t.Fatalf("openAIServiceTier(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,8 @@ func (s *turnSession) observePrefix(ctx context.Context, request zeroruntime.Com
|
||||
// computeFingerprint digests the wire-affecting request parameters: the
|
||||
// session's model and max-tokens (fixed at construction — CompletionRequest
|
||||
// carries no model field), the normalized reasoning effort as it would appear
|
||||
// on the wire, the prompt-cache key (constant within a session, and part of
|
||||
// the request the provider serializes), and an order-preserving digest of the
|
||||
// on the wire, the normalized service tier, the prompt-cache key (constant
|
||||
// within a session, and part of the request the provider serializes), and an order-preserving digest of the
|
||||
// advertised tools (request serialization preserves tool order, so a reorder
|
||||
// changes the wire bytes and counts as drift). Messages are excluded — they
|
||||
// grow every turn; this fingerprints request parameters, not conversation
|
||||
@@ -175,6 +175,8 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(openAIReasoningEffort(request.ReasoningEffort))
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(openAIServiceTier(request.ServiceTier))
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(request.PromptCacheKey)
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(wireToolsDigest(request.Tools))
|
||||
|
||||
@@ -231,11 +231,18 @@ func TestTurnSessionFingerprintDriftOnToolsAndEffort(t *testing.T) {
|
||||
// Unrecognized efforts normalize to omitted — same as empty.
|
||||
droppedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
|
||||
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
|
||||
ReasoningEffort: "xhigh",
|
||||
ReasoningEffort: "bogus",
|
||||
})
|
||||
if base != droppedEffort {
|
||||
t.Fatal("an effort the wire would omit must fingerprint like no effort")
|
||||
}
|
||||
changedTier := session.computeFingerprint(zeroruntime.CompletionRequest{
|
||||
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
|
||||
ServiceTier: "priority",
|
||||
})
|
||||
if base == changedTier {
|
||||
t.Fatal("fingerprint did not drift on a changed service tier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnSessionFingerprintDriftOnToolDescription(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ type chatCompletionRequest struct {
|
||||
Tools []toolDefinition `json:"tools,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||
// PromptCacheKey asks the backend to route the request to a replica that
|
||||
|
||||
@@ -105,7 +105,7 @@ Review.`)
|
||||
_, err = ParseMarkdown(`---
|
||||
name: reviewer
|
||||
description: Reviews code
|
||||
reasoningEffort: ULTRA
|
||||
reasoningEffort: IMPOSSIBLE
|
||||
---
|
||||
Review.`)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown reasoning effort") {
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/installtxn"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestCacheAge = 10 * time.Minute
|
||||
rankingCacheAge = 10 * time.Minute
|
||||
maxManifestBytes = 2 << 20
|
||||
maxRankingBytes = 2 << 20
|
||||
maxPreviewBytes = 2 << 20
|
||||
maxSpriteBytes = 16 << 20
|
||||
maxPetJSONBytes = 1 << 20
|
||||
maxImageSide = 16384
|
||||
maxImagePixels = 50_000_000
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
RootDir string
|
||||
ManifestURL string
|
||||
RankingURL string
|
||||
HTTPClient *http.Client
|
||||
TrustedHosts map[string]bool
|
||||
// TrustedAssetHosts is narrower than TrustedHosts: catalog redirects may use
|
||||
// petdex.dev, while image and metadata downloads must stay on the asset host.
|
||||
TrustedAssetHosts map[string]bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type compactManifest struct {
|
||||
Version int `json:"v"`
|
||||
AssetBase string `json:"assetBase"`
|
||||
Fields []string `json:"fields"`
|
||||
Pets []json.RawMessage `json:"pets"`
|
||||
}
|
||||
|
||||
type installedMetadata struct {
|
||||
Entry
|
||||
InstalledAt time.Time `json:"installedAt"`
|
||||
}
|
||||
|
||||
type petMetadata struct {
|
||||
SpriteVersion int `json:"spriteVersionNumber"`
|
||||
Animations map[string]petAnimationMetadata `json:"animations"`
|
||||
Interactions struct {
|
||||
Click struct {
|
||||
Animations []string `json:"animations"`
|
||||
} `json:"click"`
|
||||
} `json:"interactions"`
|
||||
}
|
||||
|
||||
type petAnimationMetadata struct {
|
||||
SourceRow json.RawMessage `json:"sourceRow"`
|
||||
SourceRowIndex *int `json:"sourceRowIndex"`
|
||||
FrameCount int `json:"frameCount"`
|
||||
TimingMS []int `json:"timingMs"`
|
||||
Playback string `json:"playback"`
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
func NewClient(rootDir string) *Client {
|
||||
client := &Client{
|
||||
RootDir: rootDir,
|
||||
ManifestURL: DefaultManifestURL,
|
||||
RankingURL: DefaultRankingURL,
|
||||
TrustedHosts: map[string]bool{
|
||||
"petdex.dev": true,
|
||||
TrustedAssetHost: true,
|
||||
},
|
||||
TrustedAssetHosts: map[string]bool{TrustedAssetHost: true},
|
||||
Now: time.Now,
|
||||
}
|
||||
client.HTTPClient = &http.Client{Timeout: 20 * time.Second}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) Catalog(ctx context.Context) ([]Entry, error) {
|
||||
remote, remoteErr := c.remoteCatalog(ctx)
|
||||
local, localErr := c.localCatalog()
|
||||
entries := mergeEntries(local, remote)
|
||||
if len(entries) > 0 {
|
||||
rankingCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
||||
ranking, _ := c.installedRanking(rankingCtx)
|
||||
cancel()
|
||||
sortEntriesByRanking(entries, ranking)
|
||||
return entries, nil
|
||||
}
|
||||
return nil, errors.Join(remoteErr, localErr)
|
||||
}
|
||||
|
||||
func (c *Client) InstalledEntries() ([]Entry, error) {
|
||||
return c.localCatalog()
|
||||
}
|
||||
|
||||
type rankingPayload struct {
|
||||
Pets []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"pets"`
|
||||
}
|
||||
|
||||
func (c *Client) installedRanking(ctx context.Context) ([]string, error) {
|
||||
cachePath := filepath.Join(c.cacheDir(), "ranking-installed.json")
|
||||
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < rankingCacheAge {
|
||||
if data, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if ranking, decodeErr := decodeRanking(data); decodeErr == nil {
|
||||
return ranking, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := c.fetch(ctx, c.RankingURL, maxRankingBytes)
|
||||
if err == nil {
|
||||
ranking, decodeErr := decodeRanking(data)
|
||||
if decodeErr == nil {
|
||||
_ = c.cacheFile(cachePath, data)
|
||||
return ranking, nil
|
||||
}
|
||||
err = decodeErr
|
||||
}
|
||||
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if ranking, decodeErr := decodeRanking(cached); decodeErr == nil {
|
||||
return ranking, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("load pet ranking: %w", err)
|
||||
}
|
||||
|
||||
func decodeRanking(data []byte) ([]string, error) {
|
||||
var payload rankingPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode pet ranking: %w", err)
|
||||
}
|
||||
ranking := make([]string, 0, len(payload.Pets))
|
||||
seen := make(map[string]bool, len(payload.Pets))
|
||||
for _, pet := range payload.Pets {
|
||||
slug := strings.TrimSpace(pet.Slug)
|
||||
if validateSlug(slug) != nil || seen[slug] {
|
||||
continue
|
||||
}
|
||||
seen[slug] = true
|
||||
ranking = append(ranking, slug)
|
||||
}
|
||||
if len(ranking) == 0 {
|
||||
return nil, fmt.Errorf("pet ranking is empty")
|
||||
}
|
||||
return ranking, nil
|
||||
}
|
||||
|
||||
func (c *Client) Preview(ctx context.Context, entry Entry) (*Animation, error) {
|
||||
if entry.Local {
|
||||
if animation, err := c.LoadInstalled(entry.Slug); err == nil {
|
||||
return animation, nil
|
||||
}
|
||||
}
|
||||
if err := validateSlug(entry.Slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cachePath := filepath.Join(c.previewDir(), previewCacheName(entry))
|
||||
if data, err := os.ReadFile(cachePath); err == nil {
|
||||
if animation, err := decodePreview(data); err == nil {
|
||||
return animation, nil
|
||||
}
|
||||
}
|
||||
assetBase, err := c.trustedAssetBase(entry.AssetBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previewURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/preview.webp"
|
||||
data, err := c.fetchAsset(ctx, previewURL, maxPreviewBytes)
|
||||
if err == nil {
|
||||
animation, decodeErr := decodePreview(data)
|
||||
if decodeErr == nil {
|
||||
_ = c.cacheFile(cachePath, data)
|
||||
return animation, nil
|
||||
}
|
||||
err = decodeErr
|
||||
}
|
||||
thumbnailURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/thumb.webp"
|
||||
thumbnail, thumbnailErr := c.fetchAsset(ctx, thumbnailURL, maxPreviewBytes)
|
||||
if thumbnailErr != nil {
|
||||
return nil, errors.Join(err, thumbnailErr)
|
||||
}
|
||||
imageValue, decodeErr := decodeImage(thumbnail)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
return ThumbnailAnimation(imageValue)
|
||||
}
|
||||
|
||||
func (c *Client) Install(ctx context.Context, entry Entry) (*Animation, error) {
|
||||
if err := validateSlug(entry.Slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry.Local {
|
||||
return c.LoadInstalled(entry.Slug)
|
||||
}
|
||||
spriteURL, err := c.trustedAssetURL(entry.SpritesheetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid spritesheet URL: %w", err)
|
||||
}
|
||||
spriteData, err := c.fetchAsset(ctx, spriteURL, maxSpriteBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download spritesheet: %w", err)
|
||||
}
|
||||
sheet, err := decodeImage(spriteData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode spritesheet: %w", err)
|
||||
}
|
||||
var petJSON []byte
|
||||
var animationTracks map[State]atlasTrack
|
||||
var document petMetadata
|
||||
if strings.TrimSpace(entry.PetJSONURL) != "" {
|
||||
petURL, urlErr := c.trustedAssetURL(entry.PetJSONURL)
|
||||
if urlErr != nil {
|
||||
return nil, fmt.Errorf("invalid pet metadata URL: %w", urlErr)
|
||||
}
|
||||
petJSON, err = c.fetchAsset(ctx, petURL, maxPetJSONBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download pet metadata: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(petJSON, &document); err != nil {
|
||||
return nil, fmt.Errorf("invalid pet metadata: %w", err)
|
||||
}
|
||||
if document.SpriteVersion != 0 {
|
||||
if document.SpriteVersion != 1 && document.SpriteVersion != 2 {
|
||||
return nil, fmt.Errorf("invalid pet metadata: unsupported sprite version %d", document.SpriteVersion)
|
||||
}
|
||||
entry.SpriteVersion = document.SpriteVersion
|
||||
}
|
||||
animationTracks, err = document.atlasTracks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pet metadata: %w", err)
|
||||
}
|
||||
}
|
||||
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
animation.setClickAnimations(document.Interactions.Click.Animations)
|
||||
|
||||
root := c.installedDir()
|
||||
stage, cleanup, err := installtxn.StageDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
if err := os.MkdirAll(stage, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create pet staging directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "spritesheet.webp"), spriteData, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("stage spritesheet: %w", err)
|
||||
}
|
||||
if len(petJSON) > 0 {
|
||||
if err := os.WriteFile(filepath.Join(stage, "pet.json"), petJSON, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("stage pet metadata: %w", err)
|
||||
}
|
||||
}
|
||||
metadata, err := json.MarshalIndent(installedMetadata{Entry: entry, InstalledAt: c.now()}, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "source.json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("stage pet source: %w", err)
|
||||
}
|
||||
unlock, err := installtxn.Lock(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
target := filepath.Join(root, entry.Slug)
|
||||
if err := installtxn.CommitDir(target, stage, func() error { return nil }); err != nil {
|
||||
return nil, fmt.Errorf("install pet: %w", err)
|
||||
}
|
||||
return animation, nil
|
||||
}
|
||||
|
||||
func (c *Client) LoadInstalled(slug string) (*Animation, error) {
|
||||
if err := validateSlug(slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(c.installedDir(), slug)
|
||||
entry, err := c.InstalledEntry(slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spriteData, err := os.ReadFile(filepath.Join(dir, "spritesheet.webp"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read installed spritesheet: %w", err)
|
||||
}
|
||||
sheet, err := decodeImage(spriteData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var animationTracks map[State]atlasTrack
|
||||
var document petMetadata
|
||||
if petJSON, readErr := os.ReadFile(filepath.Join(dir, "pet.json")); readErr == nil {
|
||||
if decodeErr := json.Unmarshal(petJSON, &document); decodeErr != nil {
|
||||
return nil, fmt.Errorf("read installed pet metadata: %w", decodeErr)
|
||||
}
|
||||
if document.SpriteVersion != 0 {
|
||||
entry.SpriteVersion = document.SpriteVersion
|
||||
}
|
||||
animationTracks, err = document.atlasTracks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read installed pet metadata: %w", err)
|
||||
}
|
||||
} else if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("read installed pet metadata: %w", readErr)
|
||||
}
|
||||
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
animation.setClickAnimations(document.Interactions.Click.Animations)
|
||||
return animation, nil
|
||||
}
|
||||
|
||||
func (m petMetadata) atlasTracks() (map[State]atlasTrack, error) {
|
||||
names := make([]string, 0, len(m.Animations))
|
||||
for name := range m.Animations {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
tracks := make(map[State]atlasTrack)
|
||||
for _, name := range names {
|
||||
spec := m.Animations[name]
|
||||
state := State(strings.ToLower(strings.TrimSpace(name)))
|
||||
if state == "" {
|
||||
return nil, fmt.Errorf("animation name is empty")
|
||||
}
|
||||
if spec.FrameCount < 1 || spec.FrameCount > atlasColumns {
|
||||
return nil, fmt.Errorf("animation %q has invalid frame count %d", name, spec.FrameCount)
|
||||
}
|
||||
if len(spec.TimingMS) != 0 && len(spec.TimingMS) != spec.FrameCount {
|
||||
return nil, fmt.Errorf("animation %q has %d timings for %d frames", name, len(spec.TimingMS), spec.FrameCount)
|
||||
}
|
||||
row, err := spec.rowIndex()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("animation %q: %w", name, err)
|
||||
}
|
||||
// Idle must always cycle. Treating an authored idle track as one-shot
|
||||
// leaves the companion frozen on its final frame indefinitely.
|
||||
loop := state == Idle || spec.Loop || strings.EqualFold(strings.TrimSpace(spec.Playback), "loop")
|
||||
track := atlasTrack{row: row, count: spec.FrameCount, loop: loop, fallbackIdle: !loop}
|
||||
if len(spec.TimingMS) > 0 {
|
||||
track.durations = make([]time.Duration, len(spec.TimingMS))
|
||||
for index, milliseconds := range spec.TimingMS {
|
||||
if milliseconds < 16 || milliseconds > 10_000 {
|
||||
return nil, fmt.Errorf("animation %q has invalid frame timing %dms", name, milliseconds)
|
||||
}
|
||||
track.durations[index] = time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
}
|
||||
tracks[state] = track
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (m petAnimationMetadata) rowIndex() (int, error) {
|
||||
if m.SourceRowIndex != nil {
|
||||
return *m.SourceRowIndex, nil
|
||||
}
|
||||
if len(m.SourceRow) == 0 || string(m.SourceRow) == "null" {
|
||||
return 0, fmt.Errorf("source row is missing")
|
||||
}
|
||||
var numeric int
|
||||
if err := json.Unmarshal(m.SourceRow, &numeric); err == nil {
|
||||
return numeric, nil
|
||||
}
|
||||
var name string
|
||||
if err := json.Unmarshal(m.SourceRow, &name); err != nil {
|
||||
return 0, fmt.Errorf("source row is invalid")
|
||||
}
|
||||
rows := map[string]int{
|
||||
"idle": 0, "running-right": 1, "running-left": 2, "waving": 3,
|
||||
"jumping": 4, "failed": 5, "waiting": 6, "running": 7, "review": 8,
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
if row, ok := rows[normalized]; ok {
|
||||
return row, nil
|
||||
}
|
||||
if row, err := strconv.Atoi(normalized); err == nil {
|
||||
return row, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown source row %q", name)
|
||||
}
|
||||
|
||||
func (c *Client) InstalledEntry(slug string) (Entry, error) {
|
||||
if err := validateSlug(slug); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
metadataData, err := os.ReadFile(filepath.Join(c.installedDir(), slug, "source.json"))
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("read installed pet: %w", err)
|
||||
}
|
||||
var metadata installedMetadata
|
||||
if err := json.Unmarshal(metadataData, &metadata); err != nil {
|
||||
return Entry{}, fmt.Errorf("read installed pet metadata: %w", err)
|
||||
}
|
||||
if metadata.Slug != slug {
|
||||
return Entry{}, fmt.Errorf("installed pet metadata does not match %q", slug)
|
||||
}
|
||||
metadata.Local = true
|
||||
return metadata.Entry, nil
|
||||
}
|
||||
|
||||
func (c *Client) remoteCatalog(ctx context.Context) ([]Entry, error) {
|
||||
cachePath := filepath.Join(c.cacheDir(), "petdex-v2.json")
|
||||
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < manifestCacheAge {
|
||||
if data, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if entries, decodeErr := c.decodeCatalog(data); decodeErr == nil {
|
||||
return entries, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := c.fetch(ctx, c.ManifestURL, maxManifestBytes)
|
||||
if err == nil {
|
||||
entries, decodeErr := c.decodeCatalog(data)
|
||||
if decodeErr == nil {
|
||||
_ = c.cacheFile(cachePath, data)
|
||||
return entries, nil
|
||||
}
|
||||
err = decodeErr
|
||||
}
|
||||
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if entries, decodeErr := c.decodeCatalog(cached); decodeErr == nil {
|
||||
return entries, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("load pet catalog: %w", err)
|
||||
}
|
||||
|
||||
func (c *Client) decodeCatalog(data []byte) ([]Entry, error) {
|
||||
var manifest compactManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode pet catalog: %w", err)
|
||||
}
|
||||
if manifest.Version != 2 {
|
||||
return nil, fmt.Errorf("unsupported pet catalog version %d", manifest.Version)
|
||||
}
|
||||
assetBase, err := c.trustedAssetBase(manifest.AssetBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := map[string]int{}
|
||||
for index, field := range manifest.Fields {
|
||||
wanted[field] = index
|
||||
}
|
||||
required := []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"}
|
||||
for _, field := range required {
|
||||
if _, ok := wanted[field]; !ok {
|
||||
return nil, fmt.Errorf("pet catalog is missing %q", field)
|
||||
}
|
||||
}
|
||||
entries := make([]Entry, 0, len(manifest.Pets))
|
||||
seen := map[string]bool{}
|
||||
var firstRowError error
|
||||
for _, rawRow := range manifest.Pets {
|
||||
entry, rowErr := func() (Entry, error) {
|
||||
var row []json.RawMessage
|
||||
if err := json.Unmarshal(rawRow, &row); err != nil {
|
||||
return Entry{}, fmt.Errorf("decode pet catalog row: %w", err)
|
||||
}
|
||||
value := func(field string, target any) error {
|
||||
index := wanted[field]
|
||||
if index >= len(row) {
|
||||
return fmt.Errorf("pet catalog row is missing %q", field)
|
||||
}
|
||||
return json.Unmarshal(row[index], target)
|
||||
}
|
||||
var entry Entry
|
||||
if err := value("slug", &entry.Slug); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := validateSlug(entry.Slug); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("displayName", &entry.DisplayName); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("kind", &entry.Kind); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
// submittedBy is nullable in the public manifest; null intentionally maps
|
||||
// to an empty creator label.
|
||||
_ = value("submittedBy", &entry.SubmittedBy)
|
||||
if err := value("spritesheet", &entry.SpritesheetURL); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("petJson", &entry.PetJSONURL); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("spriteVersionNumber", &entry.SpriteVersion); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if entry.SpriteVersion != 1 && entry.SpriteVersion != 2 {
|
||||
return Entry{}, fmt.Errorf("pet %q has unsupported sprite version %d", entry.Slug, entry.SpriteVersion)
|
||||
}
|
||||
entry.AssetBase = assetBase
|
||||
resolved, err := c.resolveAssetURL(assetBase, entry.SpritesheetURL)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
|
||||
}
|
||||
entry.SpritesheetURL = resolved
|
||||
if strings.TrimSpace(entry.PetJSONURL) != "" {
|
||||
resolved, err = c.resolveAssetURL(assetBase, entry.PetJSONURL)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
|
||||
}
|
||||
entry.PetJSONURL = resolved
|
||||
}
|
||||
return entry, nil
|
||||
}()
|
||||
if rowErr != nil {
|
||||
if firstRowError == nil {
|
||||
firstRowError = rowErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seen[entry.Slug] {
|
||||
continue
|
||||
}
|
||||
seen[entry.Slug] = true
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if len(entries) == 0 && firstRowError != nil {
|
||||
return nil, firstRowError
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label()) })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) localCatalog() ([]Entry, error) {
|
||||
dirs, err := os.ReadDir(c.installedDir())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]Entry, 0, len(dirs))
|
||||
for _, dir := range dirs {
|
||||
if !dir.IsDir() || validateSlug(dir.Name()) != nil {
|
||||
continue
|
||||
}
|
||||
entry, readErr := c.InstalledEntry(dir.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, address string, limit int64) ([]byte, error) {
|
||||
return c.fetchLimit(ctx, address, limit, false)
|
||||
}
|
||||
|
||||
func (c *Client) fetchAsset(ctx context.Context, address string, limit int64) ([]byte, error) {
|
||||
return c.fetchLimit(ctx, address, limit, true)
|
||||
}
|
||||
|
||||
func (c *Client) fetchLimit(ctx context.Context, address string, limit int64, assetOnly bool) ([]byte, error) {
|
||||
if _, err := c.trustedURL(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetOnly {
|
||||
if _, err := c.trustedAssetURL(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseClient := c.httpClient()
|
||||
requestClient := *baseClient
|
||||
requestClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if assetOnly {
|
||||
_, err := c.trustedAssetURL(request.URL.String())
|
||||
return err
|
||||
}
|
||||
_, err := c.trustedURL(request.URL.String())
|
||||
return err
|
||||
}
|
||||
response, err := requestClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if _, err := c.trustedURL(response.Request.URL.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetOnly {
|
||||
if _, err := c.trustedAssetURL(response.Request.URL.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("request returned %s", response.Status)
|
||||
}
|
||||
reader := io.LimitReader(response.Body, limit+1)
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("download exceeds %d-byte limit", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Client) trustedAssetBase(raw string) (string, error) {
|
||||
parsed, err := c.trustedURL(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid asset base: %w", err)
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||
return "", fmt.Errorf("asset base must not contain a path, query, or fragment")
|
||||
}
|
||||
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
|
||||
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
|
||||
}
|
||||
return strings.TrimSuffix(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func (c *Client) trustedAssetURL(raw string) (string, error) {
|
||||
parsed, err := c.trustedURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
|
||||
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("asset URL must not contain a query or fragment")
|
||||
}
|
||||
cleaned := path.Clean(parsed.Path)
|
||||
escaped := strings.ToLower(parsed.EscapedPath())
|
||||
if !allowedAssetPath(cleaned) || cleaned != parsed.Path || strings.Contains(escaped, "%2f") || strings.Contains(escaped, "%5c") || strings.Contains(parsed.Path, "\\") {
|
||||
return "", fmt.Errorf("asset path is outside the supported catalog roots")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveAssetURL(assetBase, reference string) (string, error) {
|
||||
reference = strings.TrimSpace(reference)
|
||||
if reference == "" {
|
||||
return "", fmt.Errorf("asset path is empty")
|
||||
}
|
||||
if parsed, err := url.Parse(reference); err == nil && parsed.IsAbs() {
|
||||
return c.trustedAssetURL(reference)
|
||||
}
|
||||
parsed, err := url.Parse(reference)
|
||||
if err != nil || parsed.Host != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("invalid relative asset path")
|
||||
}
|
||||
cleaned := path.Clean("/" + strings.TrimPrefix(parsed.Path, "/"))
|
||||
if !allowedAssetPath(cleaned) || strings.Contains(parsed.Path, "\\") {
|
||||
return "", fmt.Errorf("asset path is outside the supported catalog roots")
|
||||
}
|
||||
return c.trustedAssetURL(strings.TrimSuffix(assetBase, "/") + cleaned)
|
||||
}
|
||||
|
||||
func allowedAssetPath(value string) bool {
|
||||
return strings.HasPrefix(value, "/pets/") || strings.HasPrefix(value, "/community/") || strings.HasPrefix(value, "/curated/")
|
||||
}
|
||||
|
||||
func (c *Client) assetHosts() map[string]bool {
|
||||
if c.TrustedAssetHosts != nil {
|
||||
return c.TrustedAssetHosts
|
||||
}
|
||||
return map[string]bool{TrustedAssetHost: true}
|
||||
}
|
||||
|
||||
func (c *Client) trustedURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" {
|
||||
return nil, fmt.Errorf("URL must use HTTPS without credentials")
|
||||
}
|
||||
hosts := c.TrustedHosts
|
||||
if hosts == nil {
|
||||
hosts = map[string]bool{"petdex.dev": true, TrustedAssetHost: true}
|
||||
}
|
||||
if !hosts[strings.ToLower(parsed.Hostname())] {
|
||||
return nil, fmt.Errorf("untrusted host %q", parsed.Hostname())
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func decodePreview(data []byte) (*Animation, error) {
|
||||
imageValue, err := decodeImage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PreviewAnimation(imageValue)
|
||||
}
|
||||
|
||||
func previewCacheName(entry Entry) string {
|
||||
digest := sha256.Sum256([]byte(entry.SpritesheetURL))
|
||||
return fmt.Sprintf("%s-%x.webp", entry.Slug, digest[:6])
|
||||
}
|
||||
|
||||
func decodeImage(data []byte) (image.Image, error) {
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image header: %w", err)
|
||||
}
|
||||
if config.Width < 1 || config.Height < 1 || config.Width > maxImageSide || config.Height > maxImageSide || int64(config.Width)*int64(config.Height) > maxImagePixels {
|
||||
return nil, fmt.Errorf("image dimensions %dx%d exceed limits", config.Width, config.Height)
|
||||
}
|
||||
imageValue, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
return imageValue, nil
|
||||
}
|
||||
|
||||
func (c *Client) cacheFile(target string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return installtxn.WriteFileAtomically(target, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *Client) cacheDir() string { return filepath.Join(c.RootDir, "pets", "cache") }
|
||||
func (c *Client) previewDir() string { return filepath.Join(c.cacheDir(), "previews") }
|
||||
func (c *Client) installedDir() string { return filepath.Join(c.RootDir, "pets", "installed") }
|
||||
|
||||
func validateSlug(slug string) error {
|
||||
if slug == "" || len(slug) > 128 {
|
||||
return fmt.Errorf("invalid pet slug %q", slug)
|
||||
}
|
||||
for index, value := range slug {
|
||||
if (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9') || (index > 0 && (value == '-' || value == '_')) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("invalid pet slug %q", slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEntries(local, remote []Entry) []Entry {
|
||||
bySlug := make(map[string]Entry, len(local)+len(remote))
|
||||
for _, entry := range remote {
|
||||
bySlug[entry.Slug] = entry
|
||||
}
|
||||
for _, entry := range local {
|
||||
bySlug[entry.Slug] = entry
|
||||
}
|
||||
entries := make([]Entry, 0, len(bySlug))
|
||||
for _, entry := range bySlug {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].Local != entries[j].Local {
|
||||
return entries[i].Local
|
||||
}
|
||||
return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label())
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func sortEntriesByRanking(entries []Entry, ranking []string) {
|
||||
rank := make(map[string]int, len(ranking))
|
||||
for index, slug := range ranking {
|
||||
rank[slug] = index
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
left, right := entries[i], entries[j]
|
||||
if left.Local != right.Local {
|
||||
return left.Local
|
||||
}
|
||||
if left.Local {
|
||||
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
|
||||
}
|
||||
leftRank, leftRanked := rank[left.Slug]
|
||||
rightRank, rightRanked := rank[right.Slug]
|
||||
if leftRanked != rightRanked {
|
||||
return leftRanked
|
||||
}
|
||||
if leftRanked {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCatalogPreviewInstallAndOfflineReload(t *testing.T) {
|
||||
preview := encodedPNG(t, 24*previewFrameCount, 26)
|
||||
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
|
||||
var assetBase atomic.Pointer[string]
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/manifest":
|
||||
base := assetBase.Load()
|
||||
if base == nil {
|
||||
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: *base,
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
|
||||
}
|
||||
row := []any{"boba", "Boba", "animal", "tester", "pets/boba/sprite.webp", "pets/boba/petjson.json", "pets/boba/archive.zip", 2}
|
||||
encoded, _ := json.Marshal(row)
|
||||
manifest.Pets = []json.RawMessage{encoded}
|
||||
_ = json.NewEncoder(writer).Encode(manifest)
|
||||
case "/pets/boba/preview.webp":
|
||||
_, _ = writer.Write(preview)
|
||||
case "/pets/boba/sprite.webp":
|
||||
_, _ = writer.Write(atlas)
|
||||
case "/pets/boba/petjson.json":
|
||||
_, _ = writer.Write([]byte(`{"displayName":"Boba","description":"A calm blue-screen gremlin."}`))
|
||||
case "/ranking":
|
||||
_, _ = writer.Write([]byte(`{"pets":[{"slug":"boba"}],"nextCursor":null}`))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
serverURL := server.URL
|
||||
assetBase.Store(&serverURL)
|
||||
defer server.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
client := testClient(t, root, server)
|
||||
entries, err := client.Catalog(context.Background())
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("Catalog() = %#v, %v", entries, err)
|
||||
}
|
||||
entry := entries[0]
|
||||
if entry.Slug != "boba" || entry.SpriteVersion != 2 || entry.AssetBase != server.URL {
|
||||
t.Fatalf("unexpected catalog entry: %#v", entry)
|
||||
}
|
||||
if entry.SpritesheetURL != server.URL+"/pets/boba/sprite.webp" {
|
||||
t.Fatalf("relative spritesheet was not resolved: %q", entry.SpritesheetURL)
|
||||
}
|
||||
previewAnimation, err := client.Preview(context.Background(), entry)
|
||||
if err != nil || previewAnimation.Frame(Idle, 5) == nil {
|
||||
t.Fatalf("Preview() animation=%v err=%v", previewAnimation, err)
|
||||
}
|
||||
installed, err := client.Install(context.Background(), entry)
|
||||
if err != nil || installed.Frame(Running, 3) == nil {
|
||||
t.Fatalf("Install() animation=%v err=%v", installed, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "pets", "installed", "boba", "source.json")); err != nil {
|
||||
t.Fatalf("installed source marker: %v", err)
|
||||
}
|
||||
server.Close()
|
||||
offlineEntries, err := client.Catalog(context.Background())
|
||||
if err != nil || len(offlineEntries) != 1 || !offlineEntries[0].Local {
|
||||
t.Fatalf("offline Catalog() = %#v, %v", offlineEntries, err)
|
||||
}
|
||||
if _, err := client.LoadInstalled("boba"); err != nil {
|
||||
t.Fatalf("LoadInstalled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUsesPetMetadataSpriteVersionWhenManifestIsStale(t *testing.T) {
|
||||
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/pets/luffy/sprite.webp":
|
||||
_, _ = writer.Write(atlas)
|
||||
case "/pets/luffy/petjson.json":
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"spriteVersionNumber":2,
|
||||
"atlasRows":11,
|
||||
"interactions":{"click":{"animations":["bajrang-gun"],"mode":"cycle"}},
|
||||
"animations":{"bajrang-gun":{"sourceRowIndex":3,"frameCount":8,"timingMs":[100,110,120,130,140,150,160,320]}}
|
||||
}`))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := testClient(t, t.TempDir(), server)
|
||||
entry := Entry{
|
||||
Slug: "luffy",
|
||||
DisplayName: "Luffy Gear 5",
|
||||
SpritesheetURL: server.URL + "/pets/luffy/sprite.webp",
|
||||
PetJSONURL: server.URL + "/pets/luffy/petjson.json",
|
||||
SpriteVersion: 1,
|
||||
}
|
||||
animation, err := client.Install(context.Background(), entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Install() with corrected pet metadata: %v", err)
|
||||
}
|
||||
if animation.Frame(Review, 0) == nil {
|
||||
t.Fatal("Install() did not load the version 2 review row")
|
||||
}
|
||||
customState := State("bajrang-gun")
|
||||
if _, key := animation.frame(customState, 7); key.index != 7 {
|
||||
t.Fatalf("custom waving final frame index = %d, want 7", key.index)
|
||||
}
|
||||
if got := animation.FrameDelay(customState, 7); got != 320*time.Millisecond {
|
||||
t.Fatalf("custom waving final frame delay = %s, want 320ms", got)
|
||||
}
|
||||
if got := animation.PrimaryDuration(customState); got != 1230*time.Millisecond {
|
||||
t.Fatalf("custom waving duration = %s, want unchanged 1.23s", got)
|
||||
}
|
||||
if got := animation.FrameDelay(customState, 8); got != 1680*time.Millisecond {
|
||||
t.Fatalf("custom one-shot fallback delay = %s, want first idle frame at 1.68s", got)
|
||||
}
|
||||
if got, ok := animation.ClickAnimation(0); !ok || got != customState {
|
||||
t.Fatalf("click animation = %q, %v; want %q", got, ok, customState)
|
||||
}
|
||||
installed, err := client.InstalledEntry(entry.Slug)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed.SpriteVersion != 2 {
|
||||
t.Fatalf("persisted sprite version = %d, want 2", installed.SpriteVersion)
|
||||
}
|
||||
if _, err := client.LoadInstalled(entry.Slug); err != nil {
|
||||
t.Fatalf("LoadInstalled() with corrected sprite version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetMetadataAllowsSeveralNamedAnimationsOnOneRow(t *testing.T) {
|
||||
row := 0
|
||||
document := petMetadata{Animations: map[string]petAnimationMetadata{
|
||||
"blink": {SourceRowIndex: &row, FrameCount: 4, TimingMS: []int{100, 100, 100, 100}},
|
||||
"idle": {SourceRow: json.RawMessage(`"idle"`), FrameCount: 6, TimingMS: []int{200, 200, 200, 200, 200, 400}},
|
||||
}}
|
||||
tracks, err := document.atlasTracks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tracks[Idle].count; got != 6 {
|
||||
t.Fatalf("idle frame count = %d, want six", got)
|
||||
}
|
||||
if got := tracks[State("blink")].count; got != 4 {
|
||||
t.Fatalf("blink frame count = %d, want four", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetMetadataAlwaysLoopsIdleAnimation(t *testing.T) {
|
||||
row := 0
|
||||
document := petMetadata{Animations: map[string]petAnimationMetadata{
|
||||
"idle": {
|
||||
SourceRowIndex: &row,
|
||||
FrameCount: 2,
|
||||
TimingMS: []int{100, 200},
|
||||
Playback: "once",
|
||||
},
|
||||
}}
|
||||
tracks, err := document.atlasTracks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idle := tracks[Idle]
|
||||
if !idle.loop || idle.fallbackIdle {
|
||||
t.Fatalf("idle track = %#v, want looping without fallback", idle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetMetadataAcceptsNamedAndNumericSourceRows(t *testing.T) {
|
||||
document := petMetadata{Animations: map[string]petAnimationMetadata{
|
||||
"dash": {SourceRow: json.RawMessage(`"running-right"`), FrameCount: 8},
|
||||
"skill": {SourceRow: json.RawMessage(`3`), FrameCount: 8},
|
||||
}}
|
||||
tracks, err := document.atlasTracks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracks[State("dash")].row != 1 || tracks[State("skill")].row != 3 {
|
||||
t.Fatalf("normalized rows = dash:%d skill:%d", tracks[State("dash")].row, tracks[State("skill")].row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRejectsUntrustedAssetHost(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: "https://evil.example",
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
|
||||
}
|
||||
row, _ := json.Marshal([]any{"boba", "Boba", "animal", nil, "https://evil.example/pets/boba/sprite.webp", "", 2})
|
||||
manifest.Pets = []json.RawMessage{row}
|
||||
data, _ := json.Marshal(manifest)
|
||||
if _, err := client.decodeCatalog(data); err == nil {
|
||||
t.Fatal("decodeCatalog accepted an untrusted asset host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogSkipsMalformedRowWhenValidRowsRemain(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: "https://assets.petdex.dev",
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
|
||||
}
|
||||
invalid, _ := json.Marshal([]any{"broken slug", "Broken", "creature", "tester", "pets/broken/sprite.webp", "", 2})
|
||||
valid, _ := json.Marshal([]any{"boba", "Boba", "creature", "tester", "pets/boba/sprite.webp", "", 2})
|
||||
manifest.Pets = []json.RawMessage{invalid, valid}
|
||||
data, _ := json.Marshal(manifest)
|
||||
|
||||
entries, err := client.decodeCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := entrySlugs(entries); !slices.Equal(got, []string{"boba"}) {
|
||||
t.Fatalf("catalog entries = %v, want [boba]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogOrdersRankedPetsBeforeAlphabeticalRemainder(t *testing.T) {
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: "",
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
|
||||
}
|
||||
for _, row := range [][]any{
|
||||
{"alpha", "Alpha", "creature", "tester", "pets/alpha/sprite.webp", "pets/alpha/pet.json", nil, 1},
|
||||
{"beta", "Beta", "creature", "tester", "pets/beta/sprite.webp", "pets/beta/pet.json", nil, 1},
|
||||
{"zeta", "Zeta", "creature", "tester", "pets/zeta/sprite.webp", "pets/zeta/pet.json", nil, 1},
|
||||
} {
|
||||
encoded, _ := json.Marshal(row)
|
||||
manifest.Pets = append(manifest.Pets, encoded)
|
||||
}
|
||||
var rankingFails atomic.Bool
|
||||
var assetBase atomic.Pointer[string]
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/manifest":
|
||||
base := assetBase.Load()
|
||||
if base == nil {
|
||||
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
manifest.AssetBase = *base
|
||||
_ = json.NewEncoder(writer).Encode(manifest)
|
||||
case "/ranking":
|
||||
if rankingFails.Load() {
|
||||
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{"pets":[{"slug":"zeta"},{"slug":"alpha"}],"nextCursor":60}`))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
serverURL := server.URL
|
||||
assetBase.Store(&serverURL)
|
||||
defer server.Close()
|
||||
|
||||
client := testClient(t, t.TempDir(), server)
|
||||
entries, err := client.Catalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := entrySlugs(entries); !slices.Equal(got, []string{"zeta", "alpha", "beta"}) {
|
||||
t.Fatalf("ranked catalog = %v", got)
|
||||
}
|
||||
|
||||
rankingFails.Store(true)
|
||||
fallback := testClient(t, t.TempDir(), server)
|
||||
entries, err = fallback.Catalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := entrySlugs(entries); !slices.Equal(got, []string{"alpha", "beta", "zeta"}) {
|
||||
t.Fatalf("fallback catalog = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetPathAndSlugValidation(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
for _, raw := range []string{
|
||||
"https://assets.petdex.dev/other/sprite.webp",
|
||||
"https://assets.petdex.dev/pets/%2e%2e/secret",
|
||||
"https://[email protected]/pets/boba/sprite.webp",
|
||||
} {
|
||||
if _, err := client.trustedAssetURL(raw); err == nil {
|
||||
t.Errorf("trustedAssetURL(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
for _, slug := range []string{"../boba", "/boba", "Boba", "boba/other", ""} {
|
||||
if err := validateSlug(slug); err == nil {
|
||||
t.Errorf("validateSlug(%q) unexpectedly succeeded", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetRedirectToUntrustedHostIsRejectedBeforeFollowing(t *testing.T) {
|
||||
redirect := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(writer, request, "https://example.com/pets/boba/sprite.webp", http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
parsed, _ := url.Parse(redirect.URL)
|
||||
client := NewClient(t.TempDir())
|
||||
client.HTTPClient = redirect.Client()
|
||||
client.TrustedHosts = map[string]bool{parsed.Hostname(): true}
|
||||
client.TrustedAssetHosts = map[string]bool{parsed.Hostname(): true}
|
||||
if _, err := client.fetchAsset(context.Background(), redirect.URL+"/pets/boba/sprite.webp", maxSpriteBytes); err == nil {
|
||||
t.Fatal("fetchAsset followed a redirect to an untrusted host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetURLRejectsPathsOutsideCatalogRoots(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
for _, reference := range []string{"../secret.png", "/other/sprite.webp", `pets\\boba\\sprite.webp`} {
|
||||
if _, err := client.resolveAssetURL("https://assets.petdex.dev", reference); err == nil {
|
||||
t.Errorf("resolveAssetURL(%q) unexpectedly succeeded", reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAssetRejectsOversizedDownload(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte("12345"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testClient(t, t.TempDir(), server)
|
||||
if _, err := client.fetchAsset(context.Background(), server.URL+"/pets/boba/sprite.webp", 4); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("oversized fetch error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImageRejectsOversizedDimensions(t *testing.T) {
|
||||
data := encodedPNG(t, maxImageSide+1, 1)
|
||||
if _, err := decodeImage(data); err == nil || !strings.Contains(err.Error(), "dimensions") {
|
||||
t.Fatalf("oversized image error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationRejectsWrongGeometry(t *testing.T) {
|
||||
bad := image.NewNRGBA(image.Rect(0, 0, 192, 285))
|
||||
if _, err := AtlasAnimation(bad, 2); err == nil {
|
||||
t.Fatal("AtlasAnimation accepted a non-grid image")
|
||||
}
|
||||
if _, err := AtlasAnimation(image.NewNRGBA(image.Rect(0, 0, 192, 286)), 3); err == nil {
|
||||
t.Fatal("AtlasAnimation accepted an unknown sprite version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationUsesPerStateFrameCounts(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
for column := 0; column < 6; column++ {
|
||||
atlas.SetNRGBA(column*24, 0, color.NRGBA{R: 255, A: 255})
|
||||
}
|
||||
animation, err := AtlasAnimation(atlas, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Idle has six frames: phase 6 wraps to frame zero instead of entering the
|
||||
// two unused atlas columns, which are commonly transparent.
|
||||
_, _, _, alpha := animation.Frame(Idle, 6).At(0, 0).RGBA()
|
||||
if alpha == 0 {
|
||||
t.Fatal("idle phase 6 selected an unused transparent atlas column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationRunningRepeatsThreeTimesThenLoopsIdle(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
for x := 0; x < 6*24; x++ {
|
||||
for y := 0; y < 26; y++ {
|
||||
atlas.SetNRGBA(x, y, color.NRGBA{R: 255, A: 255})
|
||||
atlas.SetNRGBA(x, 7*26+y, color.NRGBA{B: 255, A: 255})
|
||||
}
|
||||
}
|
||||
animation, err := AtlasAnimation(atlas, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The six-frame running row is authored to play three times. The next
|
||||
// frame is idle, and subsequent phases loop only the appended idle row.
|
||||
running := color.NRGBAModel.Convert(animation.Frame(Running, 17).At(0, 0)).(color.NRGBA)
|
||||
settled := color.NRGBAModel.Convert(animation.Frame(Running, 18).At(0, 0)).(color.NRGBA)
|
||||
looped := color.NRGBAModel.Convert(animation.Frame(Running, 24).At(0, 0)).(color.NRGBA)
|
||||
if running.B != 255 || settled.R != 255 || looped.R != 255 {
|
||||
t.Fatalf("running transition colors = running:%#v settled:%#v looped:%#v", running, settled, looped)
|
||||
}
|
||||
if got := animation.FrameDelay(Running, 18); got != 1680*time.Millisecond {
|
||||
t.Fatalf("first fallback idle delay = %s, want 1.68s", got)
|
||||
}
|
||||
if got := animation.PrimaryDuration(Waving); got != 2100*time.Millisecond {
|
||||
t.Fatalf("waving primary duration = %s, want 2.1s", got)
|
||||
}
|
||||
if got := animation.PrimaryDuration(Jumping); got != 2520*time.Millisecond {
|
||||
t.Fatalf("jumping primary duration = %s, want 2.52s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationSuppliesDefaultTimingForMetadataWithoutTiming(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
animation, err := atlasAnimation(atlas, 1, map[State]atlasTrack{
|
||||
State("dash"): {row: 1, count: 3, fallbackIdle: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := animation.FrameDelay(State("dash"), 0); got <= 0 {
|
||||
t.Fatalf("metadata animation delay = %s, want a positive default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationIncludesInteractiveStates(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
animation, err := AtlasAnimation(atlas, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []State{MoveRight, MoveLeft, Waving, Jumping} {
|
||||
if frame := animation.Frame(state, 0); frame == nil {
|
||||
t.Fatalf("interactive state %q has no frame", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testClient(t *testing.T, root string, server *httptest.Server) *Client {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
client := NewClient(root)
|
||||
client.ManifestURL = server.URL + "/manifest"
|
||||
client.RankingURL = server.URL + "/ranking"
|
||||
client.HTTPClient = server.Client()
|
||||
client.TrustedHosts = map[string]bool{host: true}
|
||||
client.TrustedAssetHosts = map[string]bool{host: true}
|
||||
return client
|
||||
}
|
||||
|
||||
func entrySlugs(entries []Entry) []string {
|
||||
result := make([]string, len(entries))
|
||||
for index, entry := range entries {
|
||||
result[index] = entry.Slug
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func encodedPNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
value := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
value.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 180, A: 255})
|
||||
}
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := png.Encode(&output, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/installtxn"
|
||||
)
|
||||
|
||||
const kittyChunkSize = 4096
|
||||
|
||||
type ImageProtocol uint8
|
||||
|
||||
const (
|
||||
ImageProtocolNone ImageProtocol = iota
|
||||
ImageProtocolKitty
|
||||
ImageProtocolKittyLocalFile
|
||||
ImageProtocolSixel
|
||||
)
|
||||
|
||||
type ImageSupport struct {
|
||||
Protocol ImageProtocol
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (s ImageSupport) Supported() bool {
|
||||
return s.Protocol != ImageProtocolNone
|
||||
}
|
||||
|
||||
func DetectImageSupport(getenv func(string) string) ImageSupport {
|
||||
if strings.TrimSpace(getenv("TMUX")) != "" || strings.TrimSpace(getenv("TMUX_PANE")) != "" {
|
||||
return ImageSupport{Reason: "Terminal companions are disabled in tmux because terminal images are not reliably pane-local."}
|
||||
}
|
||||
if strings.TrimSpace(getenv("ZELLIJ")) != "" || strings.TrimSpace(getenv("ZELLIJ_SESSION_NAME")) != "" || strings.TrimSpace(getenv("ZELLIJ_VERSION")) != "" {
|
||||
return ImageSupport{Reason: "Terminal companions are disabled in Zellij because terminal images are not reliably pane-local."}
|
||||
}
|
||||
if strings.TrimSpace(getenv("KITTY_WINDOW_ID")) != "" || strings.TrimSpace(getenv("WEZTERM_EXECUTABLE")) != "" || strings.TrimSpace(getenv("WEZTERM_VERSION")) != "" {
|
||||
return ImageSupport{Protocol: ImageProtocolKitty}
|
||||
}
|
||||
term := strings.ToLower(getenv("TERM"))
|
||||
program := strings.ToLower(getenv("TERM_PROGRAM"))
|
||||
if strings.Contains(program, "iterm") {
|
||||
if dottedVersionAtLeast(getenv("TERM_PROGRAM_VERSION"), 3, 6, 0) {
|
||||
return ImageSupport{Protocol: ImageProtocolKittyLocalFile}
|
||||
}
|
||||
return ImageSupport{Reason: "Terminal companions require iTerm2 3.6 or newer."}
|
||||
}
|
||||
if strings.Contains(term, "ghostty") || strings.Contains(program, "ghostty") || strings.TrimSpace(getenv("GHOSTTY_RESOURCES_DIR")) != "" ||
|
||||
strings.Contains(term, "kitty") || strings.Contains(program, "kitty") || strings.Contains(term, "wezterm") || strings.Contains(program, "wezterm") {
|
||||
return ImageSupport{Protocol: ImageProtocolKitty}
|
||||
}
|
||||
if strings.TrimSpace(getenv("WT_SESSION")) != "" || strings.Contains(term, "sixel") || strings.Contains(term, "mlterm") || strings.Contains(term, "foot") {
|
||||
return ImageSupport{Protocol: ImageProtocolSixel}
|
||||
}
|
||||
return ImageSupport{Reason: "Terminal companions need Kitty graphics or Sixel image support."}
|
||||
}
|
||||
|
||||
type ImageDraw struct {
|
||||
ID uint32
|
||||
Animation *Animation
|
||||
State State
|
||||
Phase int
|
||||
X int
|
||||
Y int
|
||||
OffsetX int
|
||||
OffsetY int
|
||||
Columns int
|
||||
Rows int
|
||||
HeightPixels int
|
||||
}
|
||||
|
||||
type imageDrawKey struct {
|
||||
protocol ImageProtocol
|
||||
id uint32
|
||||
animation *Animation
|
||||
frame frameCacheKey
|
||||
x int
|
||||
y int
|
||||
offsetX int
|
||||
offsetY int
|
||||
columns int
|
||||
rows int
|
||||
height int
|
||||
}
|
||||
|
||||
type ImageRenderer struct {
|
||||
mu sync.Mutex
|
||||
support ImageSupport
|
||||
cache string
|
||||
desired *ImageDraw
|
||||
last *imageDrawKey
|
||||
}
|
||||
|
||||
func NewImageRenderer(support ImageSupport) *ImageRenderer {
|
||||
return &ImageRenderer{support: support}
|
||||
}
|
||||
|
||||
func NewImageRendererWithCache(support ImageSupport, cacheDir string) *ImageRenderer {
|
||||
return &ImageRenderer{support: support, cache: strings.TrimSpace(cacheDir)}
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Support() ImageSupport {
|
||||
if r == nil {
|
||||
return ImageSupport{Reason: "Terminal image rendering is unavailable."}
|
||||
}
|
||||
return r.support
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Set(draw *ImageDraw) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if draw == nil {
|
||||
r.desired = nil
|
||||
return
|
||||
}
|
||||
copyValue := *draw
|
||||
r.desired = ©Value
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Invalidate() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.last = nil
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Render(writer io.Writer) error {
|
||||
if r == nil || writer == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.support.Supported() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var desiredKey *imageDrawKey
|
||||
var pngBytes []byte
|
||||
if r.desired != nil && r.desired.Animation != nil {
|
||||
var frameKey frameCacheKey
|
||||
var err error
|
||||
pngBytes, frameKey, err = r.desired.Animation.framePNG(r.desired.State, r.desired.Phase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desiredKey = &imageDrawKey{
|
||||
protocol: r.support.Protocol, id: r.desired.ID, animation: r.desired.Animation, frame: frameKey,
|
||||
x: r.desired.X, y: r.desired.Y, offsetX: r.desired.OffsetX, offsetY: r.desired.OffsetY,
|
||||
columns: r.desired.Columns, rows: r.desired.Rows,
|
||||
height: r.desired.HeightPixels,
|
||||
}
|
||||
}
|
||||
if imageKeysEqual(r.last, desiredKey) {
|
||||
return nil
|
||||
}
|
||||
if kittyImageContentEqual(r.last, desiredKey) {
|
||||
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH%s\x1b[u", desiredKey.y+1, desiredKey.x+1, kittyPlace(desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
|
||||
return err
|
||||
}
|
||||
copyKey := *desiredKey
|
||||
r.last = ©Key
|
||||
return nil
|
||||
}
|
||||
if r.last != nil {
|
||||
if err := clearRenderedImage(writer, *r.last); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if desiredKey == nil {
|
||||
r.last = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH", desiredKey.y+1, desiredKey.x+1); err != nil {
|
||||
return err
|
||||
}
|
||||
switch r.support.Protocol {
|
||||
case ImageProtocolKitty:
|
||||
if _, err := io.WriteString(writer, kittyTransmitPNG(pngBytes, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ImageProtocolKittyLocalFile:
|
||||
path, err := cachePNGFrame(r.cache, pngBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(writer, kittyTransmitPNGFile(path, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ImageProtocolSixel:
|
||||
frame := r.desired.Animation.Frame(r.desired.State, r.desired.Phase)
|
||||
sixel, err := renderSixel(frame, r.desired.HeightPixels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(sixel); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(writer, "\x1b[u"); err != nil {
|
||||
return err
|
||||
}
|
||||
copyKey := *desiredKey
|
||||
r.last = ©Key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Clear(writer io.Writer) error {
|
||||
r.Set(nil)
|
||||
return r.Render(writer)
|
||||
}
|
||||
|
||||
// DeleteImages removes known application-owned Kitty image placements even when
|
||||
// this renderer no longer considers them active. This is intended for process
|
||||
// shutdown and recovery from a previous interrupted render.
|
||||
func (r *ImageRenderer) DeleteImages(writer io.Writer, ids ...uint32) error {
|
||||
if r == nil || writer == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.desired = nil
|
||||
r.last = nil
|
||||
if r.support.Protocol != ImageProtocolKitty && r.support.Protocol != ImageProtocolKittyLocalFile {
|
||||
return nil
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := io.WriteString(writer, kittyDelete(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageKeysEqual(left, right *imageDrawKey) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func kittyImageContentEqual(left, right *imageDrawKey) bool {
|
||||
if left == nil || right == nil || left.protocol != right.protocol {
|
||||
return false
|
||||
}
|
||||
if left.protocol != ImageProtocolKitty && left.protocol != ImageProtocolKittyLocalFile {
|
||||
return false
|
||||
}
|
||||
leftValue, rightValue := *left, *right
|
||||
leftValue.x, leftValue.y = 0, 0
|
||||
leftValue.offsetX, leftValue.offsetY = 0, 0
|
||||
rightValue.x, rightValue.y = 0, 0
|
||||
rightValue.offsetX, rightValue.offsetY = 0, 0
|
||||
return leftValue == rightValue
|
||||
}
|
||||
|
||||
func kittyDelete(id uint32) string {
|
||||
return fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id)
|
||||
}
|
||||
|
||||
func clearRenderedImage(writer io.Writer, key imageDrawKey) (err error) {
|
||||
if key.protocol == ImageProtocolKitty || key.protocol == ImageProtocolKittyLocalFile {
|
||||
_, err := io.WriteString(writer, kittyDelete(key.id))
|
||||
return err
|
||||
}
|
||||
if key.protocol != ImageProtocolSixel {
|
||||
return nil
|
||||
}
|
||||
// Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty
|
||||
// hands the terminal an id and the placement goes away; a sixel is already in
|
||||
// the cell grid, so the only way to remove it is to write over it. That makes
|
||||
// this the one path where the caller's SGR state matters, and it was not
|
||||
// being normalised.
|
||||
//
|
||||
// The spaces below are ordinary characters, so they carry whatever background
|
||||
// the TUI last set. Every erase therefore stamped a filled block in the
|
||||
// ambient colour, and since a drag erases and repaints on every pixel, that
|
||||
// block was continuously refreshed over the interface. It stayed invisible on
|
||||
// a terminal whose ambient background matched its theme, which is why it
|
||||
// never appeared on the Kitty-protocol terminals — ghostty, kitty, WezTerm,
|
||||
// iTerm — that do not reach this code at all. Windows Terminal is the only
|
||||
// mainstream terminal that lands here.
|
||||
//
|
||||
// DECSC/DECRC rather than CSI s/u: this now has to put back the SGR state as
|
||||
// well as the cursor, and only DECSC saves attributes. Restoring them matters
|
||||
// because the erase is emitted mid-frame, between styled writes the TUI has
|
||||
// already started.
|
||||
if _, err = io.WriteString(writer, "\x1b7\x1b[0m"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if _, restoreErr := io.WriteString(writer, "\x1b8"); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
}()
|
||||
for row := 0; row < key.rows; row++ {
|
||||
if _, err = fmt.Fprintf(writer, "\x1b[%d;%dH%s", key.y+row+1, key.x+1, strings.Repeat(" ", key.columns)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kittyTransmitPNGFile(path string, id uint32, columns, rows, offsetX, offsetY int) string {
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(path))
|
||||
return fmt.Sprintf("\x1b_Ga=T,t=f,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, payload)
|
||||
}
|
||||
|
||||
func kittyPlace(id uint32, columns, rows, offsetX, offsetY int) string {
|
||||
return fmt.Sprintf("\x1b_Ga=p,i=%d,p=%d,c=%d,r=%d%s,q=2,C=1;\x1b\\", id, id, columns, rows, kittyOffsetControl(offsetX, offsetY))
|
||||
}
|
||||
|
||||
func kittyOffsetControl(offsetX, offsetY int) string {
|
||||
if offsetX == 0 && offsetY == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(",X=%d,Y=%d", offsetX, offsetY)
|
||||
}
|
||||
|
||||
func cachePNGFrame(cacheDir string, pngBytes []byte) (string, error) {
|
||||
if cacheDir == "" {
|
||||
return "", fmt.Errorf("terminal pet image cache is unavailable")
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create terminal pet image cache: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(pngBytes)
|
||||
path := filepath.Join(cacheDir, fmt.Sprintf("%x.png", digest))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("inspect cached terminal pet frame: %w", err)
|
||||
}
|
||||
if err := installtxn.WriteFileAtomically(path, pngBytes, 0o600); err != nil {
|
||||
return "", fmt.Errorf("cache terminal pet frame: %w", err)
|
||||
}
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve cached terminal pet frame: %w", err)
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func dottedVersionAtLeast(value string, major, minor, patch int) bool {
|
||||
parts := strings.Split(strings.TrimSpace(value), ".")
|
||||
if len(parts) == 0 || len(parts) > 3 {
|
||||
return false
|
||||
}
|
||||
parsed := [3]int{}
|
||||
for index, part := range parts {
|
||||
digits := strings.TrimLeftFunc(part, func(value rune) bool { return value < '0' || value > '9' })
|
||||
end := 0
|
||||
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return false
|
||||
}
|
||||
number, err := strconv.Atoi(digits[:end])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parsed[index] = number
|
||||
}
|
||||
want := [3]int{major, minor, patch}
|
||||
for index := range parsed {
|
||||
if parsed[index] != want[index] {
|
||||
return parsed[index] > want[index]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderSixel(frame image.Image, heightPixels int) ([]byte, error) {
|
||||
if frame == nil || frame.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("pet animation has no frame to encode")
|
||||
}
|
||||
if heightPixels < 1 {
|
||||
heightPixels = 75
|
||||
}
|
||||
bounds := frame.Bounds()
|
||||
widthPixels := max(1, bounds.Dx()*heightPixels/bounds.Dy())
|
||||
scaled := image.NewNRGBA(image.Rect(0, 0, widthPixels, heightPixels))
|
||||
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), frame, bounds, xdraw.Over, nil)
|
||||
return encodeSixel(scaled)
|
||||
}
|
||||
|
||||
func kittyTransmitPNG(pngBytes []byte, id uint32, columns, rows, offsetX, offsetY int) string {
|
||||
payload := base64.StdEncoding.EncodeToString(pngBytes)
|
||||
var output strings.Builder
|
||||
for offset := 0; offset < len(payload); offset += kittyChunkSize {
|
||||
end := min(offset+kittyChunkSize, len(payload))
|
||||
more := 0
|
||||
if end < len(payload) {
|
||||
more = 1
|
||||
}
|
||||
if offset == 0 {
|
||||
fmt.Fprintf(&output, "\x1b_Ga=T,t=d,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d,m=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, more, payload[offset:end])
|
||||
} else {
|
||||
fmt.Fprintf(&output, "\x1b_Gm=%d;%s\x1b\\", more, payload[offset:end])
|
||||
}
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectImageSupportUsesKittyGraphicsInGhostty(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"TERM": "xterm-ghostty",
|
||||
"TERM_PROGRAM": "ghostty",
|
||||
}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Protocol != ImageProtocolKitty {
|
||||
t.Fatalf("Ghostty protocol = %v, want Kitty", support.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageSupportRejectsTmuxBeforeTerminalProtocol(t *testing.T) {
|
||||
env := map[string]string{"TMUX": "/tmp/tmux-1000/default,1,0", "TERM": "xterm-ghostty"}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Supported() || !strings.Contains(strings.ToLower(support.Reason), "tmux") {
|
||||
t.Fatalf("tmux support = %#v, want unsupported tmux reason", support)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageSupportUsesSixelInWindowsTerminal(t *testing.T) {
|
||||
env := map[string]string{"WT_SESSION": "session-id", "TERM": "xterm-256color"}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Protocol != ImageProtocolSixel {
|
||||
t.Fatalf("Windows Terminal protocol = %v, want Sixel", support.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageSupportUsesLocalKittyFilesInNewIterm(t *testing.T) {
|
||||
env := map[string]string{"TERM_PROGRAM": "iTerm.app", "TERM_PROGRAM_VERSION": "3.6.1"}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Protocol != ImageProtocolKittyLocalFile {
|
||||
t.Fatalf("iTerm2 protocol = %v, want Kitty local file", support.Protocol)
|
||||
}
|
||||
env["TERM_PROGRAM_VERSION"] = "3.5.9"
|
||||
support = DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Supported() || !strings.Contains(support.Reason, "3.6") {
|
||||
t.Fatalf("old iTerm2 support = %#v, want version error", support)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererTransmitsPNGAndDeletesItWhenCleared(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, G: 80, B: 120, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
|
||||
renderer.Set(&ImageDraw{
|
||||
ID: 0xC0DF,
|
||||
Animation: animation,
|
||||
State: Idle,
|
||||
Columns: 11,
|
||||
Rows: 6,
|
||||
X: 20,
|
||||
Y: 10,
|
||||
})
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
for _, want := range []string{"\x1b[11;21H", "_Ga=T,t=d,f=100,c=11,r=6,q=2,C=1,i=49375,p=49375", "iVBOR"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("Kitty output does not contain %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
renderer.Set(nil)
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, "_Ga=d,d=I,i=49375,q=2") {
|
||||
t.Fatalf("clear output did not delete image: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererMovesExistingKittyPlacementWithoutRetransmitting(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
|
||||
draw := &ImageDraw{ID: 31, Animation: animation, State: Idle, Columns: 4, Rows: 3, X: 2, Y: 3}
|
||||
renderer.Set(draw)
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
draw.X, draw.Y = 18, 9
|
||||
draw.OffsetX, draw.OffsetY = 3, 7
|
||||
renderer.Set(draw)
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "\x1b[10;19H") || !strings.Contains(got, "_Ga=p,i=31,p=31,c=4,r=3,X=3,Y=7,q=2,C=1;") {
|
||||
t.Fatalf("placement-only move missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "a=T") || strings.Contains(got, "iVBOR") || strings.Contains(got, "a=d") {
|
||||
t.Fatalf("placement-only move retransmitted or deleted image data: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererEmitsSixelForSixelTerminal(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolSixel})
|
||||
renderer.Set(&ImageDraw{ID: 9, Animation: animation, State: Idle, Columns: 4, Rows: 3, HeightPixels: 12})
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "\x1bP9;1;0q") || !strings.HasSuffix(got, "\x1b\\\x1b[u") {
|
||||
t.Fatalf("Sixel output is missing its image payload: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "_Ga=T") {
|
||||
t.Fatalf("Sixel renderer emitted Kitty graphics: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererUsesLocalPNGForIterm(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{G: 255, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRendererWithCache(ImageSupport{Protocol: ImageProtocolKittyLocalFile}, t.TempDir())
|
||||
renderer.Set(&ImageDraw{ID: 11, Animation: animation, State: Idle, Columns: 4, Rows: 3})
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
prefix := "_Ga=T,t=f,f=100,c=4,r=3,q=2,C=1,i=11,p=11;"
|
||||
start := strings.Index(got, prefix)
|
||||
if start < 0 {
|
||||
t.Fatalf("local-file command missing: %q", got)
|
||||
}
|
||||
payload := got[start+len(prefix):]
|
||||
payload = strings.SplitN(payload, "\x1b\\", 2)[0]
|
||||
pathBytes, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode local-file payload: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(string(pathBytes)); err != nil {
|
||||
t.Fatalf("cached PNG path is unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DisabledID = "disabled"
|
||||
DefaultManifestURL = "https://petdex.dev/api/manifest/v2"
|
||||
DefaultRankingURL = "https://petdex.dev/api/pets/search?sort=installed&limit=60&includeMeta=0"
|
||||
TrustedAssetHost = "assets.petdex.dev"
|
||||
previewFrameCount = 6
|
||||
atlasColumns = 8
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
Idle State = "idle"
|
||||
MoveRight State = "running-right"
|
||||
MoveLeft State = "running-left"
|
||||
Waving State = "waving"
|
||||
Jumping State = "jumping"
|
||||
Running State = "running"
|
||||
Waiting State = "waiting"
|
||||
Review State = "review"
|
||||
Failed State = "failed"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
SubmittedBy string `json:"submittedBy,omitempty"`
|
||||
SpritesheetURL string `json:"spritesheet"`
|
||||
PetJSONURL string `json:"petJson,omitempty"`
|
||||
AssetBase string `json:"assetBase,omitempty"`
|
||||
SpriteVersion int `json:"spriteVersionNumber"`
|
||||
Local bool `json:"-"`
|
||||
}
|
||||
|
||||
func (e Entry) Label() string {
|
||||
if name := strings.TrimSpace(e.DisplayName); name != "" {
|
||||
return name
|
||||
}
|
||||
return e.Slug
|
||||
}
|
||||
|
||||
type Animation struct {
|
||||
frames map[State][]image.Image
|
||||
durations map[State][]time.Duration
|
||||
loopStarts map[State]int
|
||||
clickAnimations []State
|
||||
pngMu sync.Mutex
|
||||
pngCache map[frameCacheKey][]byte
|
||||
}
|
||||
|
||||
type atlasTrack struct {
|
||||
row int
|
||||
count int
|
||||
durations []time.Duration
|
||||
repeat int
|
||||
loop bool
|
||||
fallbackIdle bool
|
||||
}
|
||||
|
||||
type frameCacheKey struct {
|
||||
state State
|
||||
index int
|
||||
}
|
||||
|
||||
func (a *Animation) Frame(state State, phase int) image.Image {
|
||||
frame, _ := a.frame(state, phase)
|
||||
return frame
|
||||
}
|
||||
|
||||
func (a *Animation) FrameDelay(state State, phase int) time.Duration {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
frames := a.frames[state]
|
||||
if len(frames) == 0 {
|
||||
state = Idle
|
||||
frames = a.frames[state]
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return 0
|
||||
}
|
||||
durations := a.durations[state]
|
||||
if len(durations) != len(frames) {
|
||||
return 0
|
||||
}
|
||||
return durations[a.frameIndex(state, phase, len(frames))]
|
||||
}
|
||||
|
||||
// PrimaryDuration reports how long a non-idle action plays before it settles
|
||||
// into its fallback loop. A zero duration means the state loops from its first
|
||||
// frame or has no configured playback boundary.
|
||||
func (a *Animation) PrimaryDuration(state State) time.Duration {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
loopStart, ok := a.loopStarts[state]
|
||||
durations := a.durations[state]
|
||||
if !ok || loopStart == 0 || loopStart > len(durations) || len(durations) == 0 {
|
||||
return 0
|
||||
}
|
||||
if loopStart < 0 {
|
||||
loopStart = len(durations)
|
||||
}
|
||||
var total time.Duration
|
||||
for _, duration := range durations[:loopStart] {
|
||||
total += duration
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ClickDuration reports the duration of an authored click action.
|
||||
// The boolean is false for ordinary activity states and built-in interactions.
|
||||
func (a *Animation) ClickDuration(state State) (time.Duration, bool) {
|
||||
if a == nil {
|
||||
return 0, false
|
||||
}
|
||||
isClick := false
|
||||
for _, candidate := range a.clickAnimations {
|
||||
if candidate == state {
|
||||
isClick = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isClick {
|
||||
return 0, false
|
||||
}
|
||||
return a.PrimaryDuration(state), true
|
||||
}
|
||||
|
||||
func (a *Animation) ClickAnimation(index int) (State, bool) {
|
||||
if a == nil || len(a.clickAnimations) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if index < 0 {
|
||||
index = -index
|
||||
}
|
||||
return a.clickAnimations[index%len(a.clickAnimations)], true
|
||||
}
|
||||
|
||||
func (a *Animation) setClickAnimations(names []string) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.clickAnimations = a.clickAnimations[:0]
|
||||
seen := make(map[State]bool, len(names))
|
||||
for _, name := range names {
|
||||
state := State(strings.ToLower(strings.TrimSpace(name)))
|
||||
if state == "" || seen[state] || len(a.frames[state]) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[state] = true
|
||||
a.clickAnimations = append(a.clickAnimations, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Animation) frame(state State, phase int) (image.Image, frameCacheKey) {
|
||||
if a == nil {
|
||||
return nil, frameCacheKey{}
|
||||
}
|
||||
frames := a.frames[state]
|
||||
if len(frames) == 0 {
|
||||
state = Idle
|
||||
frames = a.frames[Idle]
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return nil, frameCacheKey{}
|
||||
}
|
||||
index := a.frameIndex(state, phase, len(frames))
|
||||
return frames[index], frameCacheKey{state: state, index: index}
|
||||
}
|
||||
|
||||
func (a *Animation) frameIndex(state State, phase, frameCount int) int {
|
||||
if frameCount < 1 {
|
||||
return 0
|
||||
}
|
||||
if phase < 0 {
|
||||
phase = -phase
|
||||
}
|
||||
loopStart, configured := a.loopStarts[state]
|
||||
if !configured {
|
||||
return phase % frameCount
|
||||
}
|
||||
if phase < frameCount {
|
||||
return phase
|
||||
}
|
||||
if loopStart >= 0 && loopStart < frameCount {
|
||||
return loopStart + (phase-loopStart)%(frameCount-loopStart)
|
||||
}
|
||||
return frameCount - 1
|
||||
}
|
||||
|
||||
func (a *Animation) framePNG(state State, phase int) ([]byte, frameCacheKey, error) {
|
||||
frame, key := a.frame(state, phase)
|
||||
if frame == nil {
|
||||
return nil, key, fmt.Errorf("pet animation has no frames")
|
||||
}
|
||||
a.pngMu.Lock()
|
||||
defer a.pngMu.Unlock()
|
||||
if cached := a.pngCache[key]; len(cached) > 0 {
|
||||
return cached, key, nil
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, frame); err != nil {
|
||||
return nil, key, fmt.Errorf("encode pet frame: %w", err)
|
||||
}
|
||||
if a.pngCache == nil {
|
||||
a.pngCache = make(map[frameCacheKey][]byte)
|
||||
}
|
||||
value := append([]byte(nil), encoded.Bytes()...)
|
||||
a.pngCache[key] = value
|
||||
return value, key, nil
|
||||
}
|
||||
|
||||
func PreviewAnimation(sheet image.Image) (*Animation, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("preview image is empty")
|
||||
}
|
||||
bounds := sheet.Bounds()
|
||||
if bounds.Dx() < previewFrameCount || bounds.Dx()%previewFrameCount != 0 || bounds.Dy() < 1 {
|
||||
return nil, fmt.Errorf("preview must contain %d equal-width frames", previewFrameCount)
|
||||
}
|
||||
frames := splitRow(sheet, previewFrameCount, 0, bounds.Dy())
|
||||
return &Animation{
|
||||
frames: map[State][]image.Image{Idle: frames},
|
||||
durations: map[State][]time.Duration{Idle: repeatedDurations(len(frames), 140*time.Millisecond)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ThumbnailAnimation(imageValue image.Image) (*Animation, error) {
|
||||
if imageValue == nil || imageValue.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("thumbnail image is empty")
|
||||
}
|
||||
return &Animation{frames: map[State][]image.Image{Idle: {imageValue}}}, nil
|
||||
}
|
||||
|
||||
func AtlasAnimation(sheet image.Image, spriteVersion int) (*Animation, error) {
|
||||
return atlasAnimation(sheet, spriteVersion, nil)
|
||||
}
|
||||
|
||||
func atlasAnimation(sheet image.Image, spriteVersion int, overrides map[State]atlasTrack) (*Animation, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("spritesheet is empty")
|
||||
}
|
||||
rows := 9
|
||||
if spriteVersion == 2 {
|
||||
rows = 11
|
||||
} else if spriteVersion != 0 && spriteVersion != 1 {
|
||||
return nil, fmt.Errorf("unsupported sprite version %d", spriteVersion)
|
||||
}
|
||||
bounds := sheet.Bounds()
|
||||
if bounds.Dx()%atlasColumns != 0 || bounds.Dy()%rows != 0 {
|
||||
return nil, fmt.Errorf("spritesheet must be an 8x%d grid", rows)
|
||||
}
|
||||
frameWidth := bounds.Dx() / atlasColumns
|
||||
frameHeight := bounds.Dy() / rows
|
||||
if frameWidth < 1 || frameHeight < 1 || frameWidth*208 != frameHeight*192 {
|
||||
return nil, fmt.Errorf("spritesheet frames must use the 192x208 aspect ratio")
|
||||
}
|
||||
stateRows := map[State]atlasTrack{
|
||||
Idle: {row: 0, count: 6, durations: durations(1680, 660, 660, 840, 840, 1920), loop: true},
|
||||
MoveRight: {row: 1, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
|
||||
MoveLeft: {row: 2, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
|
||||
Waving: {row: 3, count: 4, durations: finalFrameDurations(4, 140, 280), repeat: 3, fallbackIdle: true},
|
||||
Jumping: {row: 4, count: 5, durations: finalFrameDurations(5, 140, 280), repeat: 3, fallbackIdle: true},
|
||||
Failed: {row: 5, count: 8, durations: finalFrameDurations(8, 140, 240), repeat: 3, fallbackIdle: true},
|
||||
Waiting: {row: 6, count: 6, durations: finalFrameDurations(6, 150, 260), repeat: 3, fallbackIdle: true},
|
||||
Running: {row: 7, count: 6, durations: finalFrameDurations(6, 120, 220), repeat: 3, fallbackIdle: true},
|
||||
Review: {row: 8, count: 6, durations: finalFrameDurations(6, 150, 280), repeat: 3, fallbackIdle: true},
|
||||
}
|
||||
for state, override := range overrides {
|
||||
stateRows[state] = override
|
||||
}
|
||||
rawFrames := make(map[State][]image.Image, len(stateRows))
|
||||
for state, spec := range stateRows {
|
||||
if spec.row < 0 || spec.row >= rows || spec.count < 1 || spec.count > atlasColumns {
|
||||
return nil, fmt.Errorf("invalid %s animation geometry", state)
|
||||
}
|
||||
if len(spec.durations) == 0 {
|
||||
spec.durations = repeatedDurations(spec.count, 180*time.Millisecond)
|
||||
stateRows[state] = spec
|
||||
} else if len(spec.durations) != spec.count {
|
||||
return nil, fmt.Errorf("%s animation has %d durations for %d frames", state, len(spec.durations), spec.count)
|
||||
}
|
||||
rawFrames[state] = splitRow(sheet, atlasColumns, spec.row*frameHeight, frameHeight)[:spec.count]
|
||||
}
|
||||
idleFrames := rawFrames[Idle]
|
||||
idleDurations := stateRows[Idle].durations
|
||||
frames := make(map[State][]image.Image, len(stateRows))
|
||||
frameDurations := make(map[State][]time.Duration, len(stateRows))
|
||||
loopStarts := make(map[State]int, len(stateRows))
|
||||
for state, spec := range stateRows {
|
||||
repeat := max(1, spec.repeat)
|
||||
for range repeat {
|
||||
frames[state] = append(frames[state], rawFrames[state]...)
|
||||
if len(spec.durations) == spec.count {
|
||||
frameDurations[state] = append(frameDurations[state], spec.durations...)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case spec.fallbackIdle && state != Idle:
|
||||
loopStarts[state] = len(frames[state])
|
||||
frames[state] = append(frames[state], idleFrames...)
|
||||
frameDurations[state] = append(frameDurations[state], idleDurations...)
|
||||
case spec.loop:
|
||||
loopStarts[state] = 0
|
||||
default:
|
||||
loopStarts[state] = -1
|
||||
}
|
||||
}
|
||||
return &Animation{frames: frames, durations: frameDurations, loopStarts: loopStarts}, nil
|
||||
}
|
||||
|
||||
func durations(milliseconds ...int) []time.Duration {
|
||||
result := make([]time.Duration, len(milliseconds))
|
||||
for index, value := range milliseconds {
|
||||
result[index] = time.Duration(value) * time.Millisecond
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func repeatedDurations(count int, duration time.Duration) []time.Duration {
|
||||
result := make([]time.Duration, count)
|
||||
for index := range result {
|
||||
result[index] = duration
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func finalFrameDurations(count, regularMilliseconds, finalMilliseconds int) []time.Duration {
|
||||
result := repeatedDurations(count, time.Duration(regularMilliseconds)*time.Millisecond)
|
||||
if len(result) > 0 {
|
||||
result[len(result)-1] = time.Duration(finalMilliseconds) * time.Millisecond
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func splitRow(sheet image.Image, count, y, height int) []image.Image {
|
||||
bounds := sheet.Bounds()
|
||||
width := bounds.Dx() / count
|
||||
frames := make([]image.Image, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
frames = append(frames, cropImage{
|
||||
source: sheet,
|
||||
bounds: image.Rect(0, 0, width, height),
|
||||
offset: image.Pt(bounds.Min.X+index*width, bounds.Min.Y+y),
|
||||
})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
type cropImage struct {
|
||||
source image.Image
|
||||
bounds image.Rectangle
|
||||
offset image.Point
|
||||
}
|
||||
|
||||
func (c cropImage) ColorModel() color.Model { return c.source.ColorModel() }
|
||||
func (c cropImage) Bounds() image.Rectangle { return c.bounds }
|
||||
func (c cropImage) At(x, y int) color.Color {
|
||||
return c.source.At(x+c.offset.X, y+c.offset.Y)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClickDurationEndsOnceAnimationWithoutGenericHold(t *testing.T) {
|
||||
action := State("custom-action")
|
||||
animation := &Animation{
|
||||
durations: map[State][]time.Duration{
|
||||
action: {200 * time.Millisecond, 100 * time.Millisecond, 300 * time.Millisecond, 200 * time.Millisecond},
|
||||
},
|
||||
loopStarts: map[State]int{action: -1},
|
||||
clickAnimations: []State{action},
|
||||
}
|
||||
|
||||
if got := animation.PrimaryDuration(action); got != 800*time.Millisecond {
|
||||
t.Fatalf("once-action duration = %s, want 800ms", got)
|
||||
}
|
||||
if got, ok := animation.ClickDuration(action); !ok || got != 800*time.Millisecond {
|
||||
t.Fatalf("click duration = %s, %t; want 800ms, true", got, ok)
|
||||
}
|
||||
if got, ok := animation.ClickDuration(Running); ok || got != 0 {
|
||||
t.Fatalf("ordinary activity duration = %s, %t; want 0, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationRejectsMismatchedOverrideDurations(t *testing.T) {
|
||||
sheet := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
_, err := atlasAnimation(sheet, 1, map[State]atlasTrack{
|
||||
Idle: {row: 0, count: 2, durations: []time.Duration{100 * time.Millisecond}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("atlasAnimation accepted mismatched override durations")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const sixelBandHeight = 6
|
||||
|
||||
func encodeSixel(source image.Image) ([]byte, error) {
|
||||
if source == nil || source.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("sixel image dimensions must be non-zero")
|
||||
}
|
||||
bounds := source.Bounds()
|
||||
width, height := bounds.Dx(), bounds.Dy()
|
||||
pixels := make([]int16, width*height)
|
||||
for index := range pixels {
|
||||
pixels[index] = -1
|
||||
}
|
||||
used := make(map[uint8]struct{})
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
red, green, blue, alpha := source.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
|
||||
if alpha>>8 < 128 {
|
||||
continue
|
||||
}
|
||||
index := uint8((red>>13)<<5 | (green>>13)<<2 | blue>>14)
|
||||
pixels[y*width+x] = int16(index)
|
||||
used[index] = struct{}{}
|
||||
}
|
||||
}
|
||||
colors := make([]int, 0, len(used))
|
||||
for index := range used {
|
||||
colors = append(colors, int(index))
|
||||
}
|
||||
sort.Ints(colors)
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString("\x1bP9;1;0q")
|
||||
fmt.Fprintf(&output, "\"1;1;%d;%d", width, height)
|
||||
for _, index := range colors {
|
||||
red := ((index >> 5) & 7) * 100 / 7
|
||||
green := ((index >> 2) & 7) * 100 / 7
|
||||
blue := (index & 3) * 100 / 3
|
||||
fmt.Fprintf(&output, "#%d;2;%d;%d;%d", index, red, green, blue)
|
||||
}
|
||||
for bandTop := 0; bandTop < height; bandTop += sixelBandHeight {
|
||||
active := activeSixelColors(pixels, width, height, bandTop, colors)
|
||||
for colorPosition, colorIndex := range active {
|
||||
fmt.Fprintf(&output, "#%d", colorIndex)
|
||||
for x := 0; x < width; x++ {
|
||||
mask := byte(0)
|
||||
for bit := 0; bit < sixelBandHeight && bandTop+bit < height; bit++ {
|
||||
if pixels[(bandTop+bit)*width+x] == int16(colorIndex) {
|
||||
mask |= 1 << bit
|
||||
}
|
||||
}
|
||||
output.WriteByte('?' + mask)
|
||||
}
|
||||
if colorPosition+1 < len(active) {
|
||||
output.WriteByte('$')
|
||||
}
|
||||
}
|
||||
if bandTop+sixelBandHeight < height {
|
||||
if len(active) > 0 {
|
||||
output.WriteByte('$')
|
||||
}
|
||||
output.WriteByte('-')
|
||||
}
|
||||
}
|
||||
output.WriteString("\x1b\\")
|
||||
return []byte(output.String()), nil
|
||||
}
|
||||
|
||||
func activeSixelColors(pixels []int16, width, height, bandTop int, colors []int) []int {
|
||||
activeSet := make(map[int16]struct{})
|
||||
for y := bandTop; y < min(height, bandTop+sixelBandHeight); y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
if color := pixels[y*width+x]; color >= 0 {
|
||||
activeSet[color] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
active := make([]int, 0, len(activeSet))
|
||||
for _, color := range colors {
|
||||
if _, ok := activeSet[int16(color)]; ok {
|
||||
active = append(active, color)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A sixel erase paints characters, so it must not inherit the caller's colours.
|
||||
//
|
||||
// This is the black rectangle reported on Windows Terminal. The erase writes
|
||||
// literal spaces over the cells the image occupied, and those spaces are drawn
|
||||
// with whatever SGR was last set; without a reset every erase stamps a filled
|
||||
// block in the ambient background. A drag erases and repaints on every pixel, so
|
||||
// the block is continuously refreshed over the interface.
|
||||
//
|
||||
// It never showed on ghostty, kitty, WezTerm or iTerm because those resolve to
|
||||
// the Kitty protocol and delete by image id, never reaching this path. Windows
|
||||
// Terminal is the only mainstream terminal that does, which is why the platform
|
||||
// looked at fault when the real cause is an untested code path.
|
||||
func TestSixelEraseDoesNotPaintWithTheCallersColours(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
|
||||
if err := clearRenderedImage(&out, key); err != nil {
|
||||
t.Fatalf("clearRenderedImage: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
reset := strings.Index(got, "\x1b[0m")
|
||||
if reset < 0 {
|
||||
t.Fatalf("the erase never resets SGR, so it paints a block in the ambient background: %q", got)
|
||||
}
|
||||
// Before the first space, not merely present somewhere.
|
||||
if firstSpace := strings.Index(got, strings.Repeat(" ", key.columns)); firstSpace >= 0 && reset > firstSpace {
|
||||
t.Errorf("SGR is reset only after the first row is painted, so that row still carries the caller's colours: %q", got)
|
||||
}
|
||||
// DECSC/DECRC, because only those restore the attributes this now clobbers.
|
||||
// CSI s/u save the cursor alone and would leave the TUI drawing in the reset
|
||||
// state for the remainder of the frame.
|
||||
if !strings.HasPrefix(got, "\x1b7") {
|
||||
t.Errorf("erase must open with DECSC to save cursor AND attributes, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\x1b8") {
|
||||
t.Errorf("erase must close with DECRC to restore them, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The erase still covers exactly the cells the image claimed, or the fix above
|
||||
// would be satisfied by an erase that does nothing.
|
||||
func TestSixelEraseStillCoversEveryClaimedCell(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
|
||||
if err := clearRenderedImage(&out, key); err != nil {
|
||||
t.Fatalf("clearRenderedImage: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for row := 0; row < key.rows; row++ {
|
||||
position := fmt.Sprintf("\x1b[%d;%dH", key.y+row+1, key.x+1)
|
||||
erase := position + strings.Repeat(" ", key.columns)
|
||||
if !strings.Contains(got, erase) {
|
||||
t.Fatalf("erase does not position row %d at %q: %q", row, position, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, strings.Repeat(" ", key.columns)) != key.rows {
|
||||
t.Errorf("erase covered %d rows of %d columns, want %d rows: %q",
|
||||
strings.Count(got, strings.Repeat(" ", key.columns)), key.columns, key.rows, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixelEraseRestoresTerminalStateAfterRowWriteFailure(t *testing.T) {
|
||||
rowErr := errors.New("row write failed")
|
||||
writer := &failOnceWriter{failOnWrite: 2, err: rowErr}
|
||||
key := imageDrawKey{protocol: ImageProtocolSixel, x: 4, y: 2, columns: 3, rows: 2}
|
||||
|
||||
err := clearRenderedImage(writer, key)
|
||||
if !errors.Is(err, rowErr) {
|
||||
t.Fatalf("clearRenderedImage error = %v, want primary row error", err)
|
||||
}
|
||||
if !strings.HasSuffix(writer.String(), "\x1b8") {
|
||||
t.Fatalf("terminal state was not restored after row failure: %q", writer.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failOnceWriter struct {
|
||||
bytes.Buffer
|
||||
writes int
|
||||
failOnWrite int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *failOnceWriter) Write(value []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failOnWrite {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.Buffer.Write(value)
|
||||
}
|
||||
|
||||
// Kitty must NOT gain the character-painting erase: it deletes by image id, and
|
||||
// writing spaces there would erase cells the terminal is about to redraw itself.
|
||||
func TestKittyEraseStillDeletesByImageID(t *testing.T) {
|
||||
for _, protocol := range []ImageProtocol{ImageProtocolKitty, ImageProtocolKittyLocalFile} {
|
||||
var out bytes.Buffer
|
||||
if err := clearRenderedImage(&out, imageDrawKey{protocol: protocol, id: 7, columns: 6, rows: 3}); err != nil {
|
||||
t.Fatalf("clearRenderedImage: %v", err)
|
||||
}
|
||||
if got := out.String(); strings.Contains(got, " ") || !strings.Contains(got, "\x1b_G") {
|
||||
t.Errorf("protocol %v should delete by id, not paint cells: %q", protocol, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,10 @@ func pasteFromClipboardCmd() tea.Cmd {
|
||||
// shared by the terminal bracketed-paste handler (tea.PasteMsg) and the
|
||||
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
|
||||
// paste behave identically. Surfaces with no editable text field (a permission/
|
||||
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
|
||||
// the paste. The session rename editor accepts text only; empty content is a
|
||||
// no-op there rather than an image probe.
|
||||
// spec prompt, the MCP manager, or the detailed transcript) swallow the paste.
|
||||
// Searchable pickers consume pasted text through the same filtering path as
|
||||
// typed characters. The session rename editor accepts text only; empty content
|
||||
// is a no-op there rather than an image probe.
|
||||
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// A paste is a deliberate action, same as a keypress or click — it means
|
||||
// the user moved on to something else, so it disarms a stale Esc
|
||||
@@ -101,7 +102,18 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
if m.providerWizard != nil {
|
||||
return m.handleProviderWizardPaste(content)
|
||||
}
|
||||
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil {
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil {
|
||||
return m, nil
|
||||
}
|
||||
// A drag-dropped image/PDF arrives as a (backslash-escaped) file path. Attach
|
||||
|
||||
@@ -456,6 +456,7 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// Keep sub-agent child processes on the same provider we just switched to.
|
||||
config.SetActiveProviderEnv(nextProfile.Name)
|
||||
m.modelName = target.modelID
|
||||
m.serviceTier = ""
|
||||
// Record the outgoing pair too, not just the destination: otherwise the
|
||||
// model a session started on (never itself the target of a recordRecentModel
|
||||
// call) would silently drop out of "Recent" the moment you switch away from
|
||||
@@ -559,6 +560,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
m.providerProfile = target
|
||||
m.providerName = target.Name
|
||||
m.modelName = target.Model
|
||||
m.serviceTier = ""
|
||||
// An active profile's effort fill is per-model: re-derive it for the
|
||||
// destination, exactly like handleModelCommand does. No generic
|
||||
// unsupported-drop here: cross-provider targets are often custom models
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
commandCompact
|
||||
commandRewind
|
||||
commandEffort
|
||||
commandFast
|
||||
commandStyle
|
||||
commandTheme
|
||||
commandTranscript
|
||||
@@ -53,6 +54,7 @@ const (
|
||||
commandGoal
|
||||
commandVoice
|
||||
commandSTTModel
|
||||
commandPets
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -270,11 +272,18 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/effort",
|
||||
usage: "/effort [list|low|medium|high|auto]",
|
||||
usage: "/effort [list|level|auto]",
|
||||
group: commandGroupModel,
|
||||
description: "Show or set reasoning effort for supported models.",
|
||||
kind: commandEffort,
|
||||
},
|
||||
{
|
||||
name: "/fast",
|
||||
usage: "/fast",
|
||||
group: commandGroupModel,
|
||||
description: "Toggle fast mode for supported ChatGPT subscription models.",
|
||||
kind: commandFast,
|
||||
},
|
||||
{
|
||||
name: "/style",
|
||||
usage: "/style [list|balanced|concise|explanatory|review]",
|
||||
@@ -353,6 +362,14 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show available commands.",
|
||||
kind: commandHelp,
|
||||
},
|
||||
{
|
||||
name: "/pets",
|
||||
aliases: []string{"/pet"},
|
||||
usage: "/pets [name|off]",
|
||||
group: commandGroupMeta,
|
||||
description: "Choose, preview, or hide a terminal companion.",
|
||||
kind: commandPets,
|
||||
},
|
||||
{
|
||||
name: "/doctor",
|
||||
aliases: []string{"/health"},
|
||||
|
||||
@@ -47,7 +47,8 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
|
||||
"model:",
|
||||
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
|
||||
" /model [list|id] - Show or switch the active model.",
|
||||
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
|
||||
" /effort [list|level|auto] - Show or set reasoning effort for supported models.",
|
||||
" /fast - Toggle fast mode for supported ChatGPT subscription models.",
|
||||
"session:",
|
||||
" /plan [status|on|off] - Show plan status, or enter/exit read-only planning mode.",
|
||||
"runtime:",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
func TestFastCommandTogglesOnlyAdvertisedTier(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-fast",
|
||||
providerName: "ChatGPT",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
}
|
||||
m, _ = m.handleFastCommand("")
|
||||
if got := m.activeServiceTier(); got != "priority" {
|
||||
t.Fatalf("active service tier = %q, want priority", got)
|
||||
}
|
||||
m, _ = m.handleFastCommand("")
|
||||
if got := m.activeServiceTier(); got != "" {
|
||||
t.Fatalf("active service tier = %q after second toggle, want off", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandRejectsUnsupportedModel(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-standard",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-standard"}},
|
||||
},
|
||||
serviceTier: "priority",
|
||||
}
|
||||
next, text := m.handleFastCommand("")
|
||||
if next.activeServiceTier() != "" || next.serviceTier != "" {
|
||||
t.Fatalf("unsupported model retained fast tier: %#v", next.serviceTier)
|
||||
}
|
||||
if !strings.Contains(text, "ChatGPT subscription") {
|
||||
t.Fatalf("unsupported response = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandRejectsMeteredPriorityTier(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-metered",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "openai"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"openai": {{ID: "gpt-metered", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
serviceTier: "priority",
|
||||
}
|
||||
|
||||
next, text := m.handleFastCommand("")
|
||||
if got := next.activeServiceTier(); got != "" {
|
||||
t.Fatalf("metered profile forwarded service tier %q", got)
|
||||
}
|
||||
if next.serviceTier != "" {
|
||||
t.Fatalf("metered profile retained service tier %q", next.serviceTier)
|
||||
}
|
||||
if !strings.Contains(text, "ChatGPT subscription") {
|
||||
t.Fatalf("metered response = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandDoesNotChangeDuringRun(t *testing.T) {
|
||||
m := model{
|
||||
pending: true,
|
||||
modelName: "gpt-fast",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
}
|
||||
next, text := m.handleFastCommand("")
|
||||
if next.serviceTier != "" || !strings.Contains(text, "Finish or stop") {
|
||||
t.Fatalf("pending fast mutation = tier %q text %q", next.serviceTier, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandDoesNotInterpretArguments(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-fast",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
}
|
||||
next, text := m.handleFastCommand("status")
|
||||
if next.serviceTier != "" || !strings.Contains(text, "Use /fast to toggle") {
|
||||
t.Fatalf("argument changed fast state: tier %q text %q", next.serviceTier, text)
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil, true
|
||||
}
|
||||
m.pickerMoved(delta)
|
||||
return m, nil, true
|
||||
m, cmd := m.pickerMoved(delta)
|
||||
return m, cmd, true
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(delta)
|
||||
|
||||
+176
-10
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -33,6 +35,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/usercommands"
|
||||
@@ -138,6 +141,7 @@ type model struct {
|
||||
permissionModeBeforePlan agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
serviceTier string
|
||||
// Active execution profile (set by /profile; applies to the NEXT run).
|
||||
// The displaced/applied pairs let a switch or /profile balanced restore
|
||||
// exactly what the profile replaced while leaving later manual overrides
|
||||
@@ -155,6 +159,48 @@ type model struct {
|
||||
execProfileEffortTouched bool
|
||||
execProfileSelfCorrectTouched bool
|
||||
responseStyle string
|
||||
petClient *terminalpet.Client
|
||||
petRenderer *terminalpet.ImageRenderer
|
||||
petEntries map[string]terminalpet.Entry
|
||||
petID string
|
||||
petName string
|
||||
petAnimation *terminalpet.Animation
|
||||
petPreview *terminalpet.Animation
|
||||
petPreviewSlug string
|
||||
petPreviewError string
|
||||
petPreviewLoading bool
|
||||
petPreviewSeq uint64
|
||||
petPreviewCancel context.CancelFunc
|
||||
petRequestedSlug string
|
||||
petPhase int
|
||||
petTickSeq uint64
|
||||
petPlaybackState terminalpet.State
|
||||
petClickAnimationIndex int
|
||||
petOutcome terminalpet.State
|
||||
petOutcomeAt time.Time
|
||||
petLayoutRendering bool
|
||||
petPositionSet bool
|
||||
petPositionX int
|
||||
petPositionY int
|
||||
petDragActive bool
|
||||
petDragMoved bool
|
||||
petDragStartedDocked bool
|
||||
petDragOffsetX int
|
||||
petDragOffsetY int
|
||||
petDragTargetX int
|
||||
petDragTargetY int
|
||||
petDragTargetOffsetX int
|
||||
petDragTargetOffsetY int
|
||||
petPositionOffsetX int
|
||||
petPositionOffsetY int
|
||||
petCellPixelWidth int
|
||||
petCellPixelHeight int
|
||||
petPixelDrag bool
|
||||
petPixelAnchorSet bool
|
||||
petDragOffsetPixelX int
|
||||
petDragOffsetPixelY int
|
||||
petDragState terminalpet.State
|
||||
petLastClickAt time.Time
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
@@ -928,6 +974,8 @@ func newModel(ctx context.Context, options Options) model {
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
petEntries: map[string]terminalpet.Entry{},
|
||||
petID: strings.TrimSpace(options.SavedPet),
|
||||
keyBindings: resolvedKeyBindings,
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
@@ -951,6 +999,22 @@ func newModel(ctx context.Context, options Options) model {
|
||||
setupSave: options.Setup.Save,
|
||||
dictation: newDictationController(options),
|
||||
}
|
||||
petRoot := userConfigDir
|
||||
if strings.TrimSpace(options.UserConfigPath) != "" {
|
||||
petRoot = filepath.Dir(options.UserConfigPath)
|
||||
}
|
||||
if strings.TrimSpace(petRoot) != "" {
|
||||
m.petClient = terminalpet.NewClient(petRoot)
|
||||
if m.petID != "" && m.petID != terminalpet.DisabledID {
|
||||
if animation, loadErr := m.petClient.LoadInstalled(m.petID); loadErr == nil {
|
||||
m.petAnimation = animation
|
||||
m.petName = m.petID
|
||||
if entry, entryErr := m.petClient.InstalledEntry(m.petID); entryErr == nil {
|
||||
m.petName = entry.Label()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||||
if m.themeMode != themeAuto {
|
||||
@@ -1033,6 +1097,17 @@ func composerBlinkCmd() tea.Cmd {
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
|
||||
if m.petAnimation != nil && !m.reducedMotion {
|
||||
cmds = append(cmds, petTickCmd(m.petTickSeq, m.petFrameDelay()))
|
||||
}
|
||||
// Every image protocol wants this, not just the ones that support pixel
|
||||
// dragging. Sixel needs it most: it erases itself by writing over cells, so
|
||||
// without the pixels-per-cell figure it cannot know how many cells it
|
||||
// covered, and falls back to constants describing the reserved area rather
|
||||
// than the image.
|
||||
if m.petCellMetricsWanted() {
|
||||
cmds = append(cmds, tea.Raw(ansi.WindowOp(16)))
|
||||
}
|
||||
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
|
||||
// on program start, so m.height/m.width are normally set before the first
|
||||
// render. But that's the terminal proactively pushing a size — if it's
|
||||
@@ -1234,6 +1309,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return next, cmd
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case uv.CellSizeEvent:
|
||||
if msg.Width > 0 && msg.Height > 0 {
|
||||
m.petCellPixelWidth = msg.Width
|
||||
m.petCellPixelHeight = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
case peerMessageMsg:
|
||||
admitted := m.canAcceptPeerMessage(msg.message)
|
||||
if msg.admit != nil {
|
||||
@@ -1396,6 +1477,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyPressMsg:
|
||||
if m.petDragActive {
|
||||
pixelDrag := m.petPixelDrag
|
||||
if !keyIs(msg, tea.KeyEsc) && !keyCtrl(msg, 'c') {
|
||||
return m, nil
|
||||
}
|
||||
m.cancelPetDrag()
|
||||
m.lastKeyTime = time.Time{}
|
||||
m.burstCount = 0
|
||||
if pixelDrag {
|
||||
return m, petPixelMouseDisableCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// Paste-detection timing trackers. MUST run before any early return
|
||||
// so burst counting stays accurate regardless of which branch fires.
|
||||
now := m.now()
|
||||
@@ -1607,6 +1701,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// committed palette since Esc dismisses without choosing.
|
||||
m.restoreCommittedTheme()
|
||||
}
|
||||
if m.picker.kind == pickerPet {
|
||||
m.cancelPetPreview()
|
||||
}
|
||||
m.picker = nil
|
||||
return m, nil
|
||||
}
|
||||
@@ -1835,6 +1932,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Editing the filter changes which row is highlighted; keep the
|
||||
// theme preview in sync with it (no-op for other pickers).
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// On an empty composer, Backspace removes the last attachment chip
|
||||
@@ -2057,6 +2157,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Filtering changes the highlighted row; keep the theme preview in
|
||||
// sync with it (no-op for other pickers).
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -2085,12 +2188,22 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.BlurMsg:
|
||||
var petMouseCmd tea.Cmd
|
||||
if m.petDragActive {
|
||||
pixelDrag := m.petPixelDrag
|
||||
m.cancelPetDrag()
|
||||
m.lastKeyTime = time.Time{}
|
||||
m.burstCount = 0
|
||||
if pixelDrag {
|
||||
petMouseCmd = petPixelMouseDisableCmd()
|
||||
}
|
||||
}
|
||||
m.terminalFocused = false
|
||||
m.composerCursorVisible = false
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(false)
|
||||
}
|
||||
return m, nil
|
||||
return m, petMouseCmd
|
||||
case toolCallStreamStartMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
return m, nil
|
||||
@@ -2261,6 +2374,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.resizeFreePetPosition(m.width, m.height, msg.Width, msg.Height)
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
// A resize re-wraps content at a new width, shifting every row's bodyY;
|
||||
@@ -2419,6 +2533,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
|
||||
if msg.err != nil {
|
||||
m.petOutcome = terminalpet.Failed
|
||||
} else {
|
||||
m.petOutcome = terminalpet.Review
|
||||
}
|
||||
m.petOutcomeAt = m.now()
|
||||
m.pending = false
|
||||
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
|
||||
// A newline-triggered redraw deferred by the stream-clear throttle
|
||||
@@ -2807,6 +2927,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.applySetupOAuthDeviceCode(msg)
|
||||
case modelPickerModelsDiscoveredMsg:
|
||||
return m.applyModelPickerModelsDiscovered(msg), nil
|
||||
case petCatalogLoadedMsg:
|
||||
return m.applyPetCatalog(msg)
|
||||
case petPreviewDebounceMsg:
|
||||
return m.startPetPreview(msg)
|
||||
case petPreviewLoadedMsg:
|
||||
m = m.applyPetPreview(msg)
|
||||
if m.petPreview != nil && m.petAnimation == nil && !m.reducedMotion {
|
||||
m.petTickSeq++
|
||||
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
|
||||
}
|
||||
return m, nil
|
||||
case petInstalledMsg:
|
||||
return m.applyPetInstall(msg)
|
||||
case petTickMsg:
|
||||
if msg.seq != m.petTickSeq {
|
||||
return m, nil
|
||||
}
|
||||
if (m.petAnimation == nil && (m.picker == nil || m.petPreview == nil)) || m.reducedMotion {
|
||||
return m, nil
|
||||
}
|
||||
_, state := m.petPlayback()
|
||||
if state != m.petPlaybackState {
|
||||
m.petPlaybackState = state
|
||||
m.petPhase = 0
|
||||
} else {
|
||||
m.petPhase++
|
||||
}
|
||||
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
|
||||
case ollamaContextWindowDiscoveredMsg:
|
||||
if msg.err == nil && msg.contextWindow > 0 {
|
||||
if m.ollamaContextWindowByModel == nil {
|
||||
@@ -2836,6 +2984,9 @@ func (m model) View() tea.View {
|
||||
} else {
|
||||
content = m.detailedTranscriptView()
|
||||
}
|
||||
if m.petRenderer != nil {
|
||||
m.petRenderer.Set(m.petImageDraw(content))
|
||||
}
|
||||
|
||||
view := tea.NewView(content)
|
||||
view.AltScreen = m.altScreen
|
||||
@@ -2908,6 +3059,9 @@ func (m model) transcriptView() string {
|
||||
if m.sidebarActive() && !m.subchat.active {
|
||||
return m.twoColumnTranscriptView()
|
||||
}
|
||||
if m.petLayoutActive() {
|
||||
return m.floatingPetTranscriptView()
|
||||
}
|
||||
|
||||
width := chatWidth(m.width)
|
||||
|
||||
@@ -3012,7 +3166,6 @@ func (m model) twoColumnTranscriptView() string {
|
||||
|
||||
header := m.pinnedTitleBar(width)
|
||||
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
|
||||
|
||||
sidebar := m.renderContextSidebar(sidebarW, len(chatBlock))
|
||||
rows := joinColumns(chatBlock, sidebar, chatW, sidebarW)
|
||||
return strings.Join(rows, "\n")
|
||||
@@ -3040,7 +3193,7 @@ func (m model) footerView(width int) string {
|
||||
if m.renamePrompt != nil {
|
||||
footer.WriteString(m.sessionRenamePromptView(width))
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// While an ask-user questionnaire is active it REPLACES the composer box (the
|
||||
@@ -3049,7 +3202,7 @@ func (m model) footerView(width int) string {
|
||||
if m.pendingAskUser != nil {
|
||||
footer.WriteString(renderAskUserQuestionnaire(*m.pendingAskUser, m.input.Value(), width))
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// A focused permission prompt owns the keyboard: its options (and the feedback
|
||||
@@ -3059,7 +3212,7 @@ func (m model) footerView(width int) string {
|
||||
// input from echoing in two places once "tell Zero what to do differently"
|
||||
// opens the on-card feedback field.
|
||||
if m.pendingPermission != nil {
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// Pinned plan panel: sits directly above the composer so it stays visible
|
||||
@@ -3100,7 +3253,7 @@ func (m model) footerView(width int) string {
|
||||
footer.WriteString(hint)
|
||||
}
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
|
||||
@@ -4124,23 +4277,26 @@ func (m model) composerBox(width int) string {
|
||||
if width < 8 {
|
||||
return fitStyledLine(m.composerLine(width), width)
|
||||
}
|
||||
innerWidth := maxInt(1, width-4)
|
||||
reserved := m.petComposerReservedColumns(width)
|
||||
boxWidth := width - reserved
|
||||
innerWidth := maxInt(1, boxWidth-4)
|
||||
content := m.composerLine(innerWidth)
|
||||
lines := strings.Split(content, "\n")
|
||||
rightPad := strings.Repeat(" ", reserved)
|
||||
|
||||
rendered := make([]string, 0, len(lines)+3)
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", boxWidth-2)+"╮")+rightPad)
|
||||
// Attachment chips ([Image #1] …) render INSIDE the box, above the input line,
|
||||
// instead of as a separate row above the box.
|
||||
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
|
||||
fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth)
|
||||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
|
||||
}
|
||||
for _, line := range lines {
|
||||
fitted := fitStyledLine(line, innerWidth)
|
||||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
|
||||
}
|
||||
rendered = append(rendered, m.composerDividerLine(width))
|
||||
return strings.Join(rendered, "\n")
|
||||
@@ -4347,6 +4503,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case pickerSTTDownload:
|
||||
return m.handleSTTDownloadSelection(item.Value)
|
||||
case pickerPet:
|
||||
return m.installPet(item.Value)
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
@@ -4503,6 +4661,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandGoal:
|
||||
return m.handleGoalCommand(command.text)
|
||||
case commandPets:
|
||||
return m.handlePetsCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
@@ -4681,6 +4841,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleEffortCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandFast:
|
||||
text := ""
|
||||
m, text = m.handleFastCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandStyle:
|
||||
text := ""
|
||||
m, text = m.handleStyleCommand(command.text)
|
||||
@@ -5249,6 +5414,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
options.ProviderName = m.providerName
|
||||
options.Model = m.modelName
|
||||
options.ReasoningEffort = string(m.reasoningEffort)
|
||||
options.ServiceTier = m.activeServiceTier()
|
||||
options.ResponseStyle = m.responseStyle
|
||||
options.Cwd = m.cwd
|
||||
options.Images = images
|
||||
|
||||
@@ -198,6 +198,7 @@ func TestParseCommand(t *testing.T) {
|
||||
{input: "/plan off", kind: commandPlan, text: "off"},
|
||||
{input: "/compact", kind: commandCompact},
|
||||
{input: "/effort high", kind: commandEffort, text: "high"},
|
||||
{input: "/fast", kind: commandFast},
|
||||
{input: "/style concise", kind: commandStyle, text: "concise"},
|
||||
{input: "/debug-mode", kind: commandDebug},
|
||||
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
|
||||
|
||||
@@ -151,6 +151,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if mouseRightPress(msg) {
|
||||
return m, pasteFromClipboardCmd()
|
||||
}
|
||||
if next, cmd, handled := m.handlePetMouse(msg); handled {
|
||||
return next, cmd
|
||||
}
|
||||
if mouseLeftPress(msg) {
|
||||
switch {
|
||||
case m.providerWizard != nil:
|
||||
@@ -187,6 +190,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return m.choosePicker()
|
||||
}
|
||||
m.lastMouseSelection = target
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case m.suggestionsActive():
|
||||
@@ -233,8 +239,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
return m.pickerMoved(-1)
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(-1)
|
||||
@@ -265,8 +270,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
return m.pickerMoved(1)
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(1)
|
||||
|
||||
@@ -14,15 +14,29 @@ func mouseEventFilter() func(tea.Model, tea.Msg) tea.Msg {
|
||||
|
||||
func newMouseEventFilter(now func() time.Time, minInterval time.Duration) func(tea.Model, tea.Msg) tea.Msg {
|
||||
var last time.Time
|
||||
return func(_ tea.Model, msg tea.Msg) tea.Msg {
|
||||
return func(current tea.Model, msg tea.Msg) tea.Msg {
|
||||
switch msg.(type) {
|
||||
case tea.MouseWheelMsg, tea.MouseMotionMsg:
|
||||
current := now()
|
||||
if !last.IsZero() && current.Sub(last) < minInterval {
|
||||
if _, motion := msg.(tea.MouseMotionMsg); motion && petDragActive(current) {
|
||||
return msg
|
||||
}
|
||||
timestamp := now()
|
||||
if !last.IsZero() && timestamp.Sub(last) < minInterval {
|
||||
return nil
|
||||
}
|
||||
last = current
|
||||
last = timestamp
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func petDragActive(current tea.Model) bool {
|
||||
switch current := current.(type) {
|
||||
case model:
|
||||
return current.petDragActive
|
||||
case *model:
|
||||
return current != nil && current.petDragActive
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,22 @@ func TestMouseEventFilterDoesNotThrottleKeyboard(t *testing.T) {
|
||||
t.Fatal("keyboard events should not touch the mouse throttle clock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseEventFilterDoesNotThrottlePetDrag(t *testing.T) {
|
||||
clockReads := 0
|
||||
filter := newMouseEventFilter(func() time.Time {
|
||||
clockReads++
|
||||
return time.Unix(0, 0)
|
||||
}, mouseEventThrottleInterval)
|
||||
m := model{petDragActive: true}
|
||||
|
||||
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 1, Y: 1})); got == nil {
|
||||
t.Fatal("first drag event should pass through")
|
||||
}
|
||||
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 2, Y: 1})); got == nil {
|
||||
t.Fatal("every pet-drag event should pass through for zero-gap tracking")
|
||||
}
|
||||
if clockReads != 0 {
|
||||
t.Fatalf("pet drag should bypass the throttle clock, read it %d times", clockReads)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,10 @@ type Options struct {
|
||||
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
|
||||
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
|
||||
SavedTheme string
|
||||
UserAgent string
|
||||
// SavedPet is the persisted terminal companion id. Empty means no pet has
|
||||
// been selected yet; "disabled" records an explicit opt-out.
|
||||
SavedPet string
|
||||
UserAgent string
|
||||
|
||||
// Notify configures completion / awaiting-input notifications.
|
||||
Notify config.NotifyConfig
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
const (
|
||||
terminalSyncStart = "\x1b[?2026h"
|
||||
terminalSyncEnd = "\x1b[?2026l"
|
||||
)
|
||||
|
||||
type terminalOutputFile interface {
|
||||
io.ReadWriteCloser
|
||||
Fd() uintptr
|
||||
}
|
||||
|
||||
type petImageOutput struct {
|
||||
output terminalOutputFile
|
||||
renderer *terminalpet.ImageRenderer
|
||||
renderDisabled bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
|
||||
return &petImageOutput{output: output, renderer: renderer}
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Read(value []byte) (int, error) {
|
||||
return o.output.Read(value)
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
|
||||
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
|
||||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
|
||||
if leavingAltScreen && !kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if leavingAltScreen {
|
||||
written, err := o.output.Write(value)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
if kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
|
||||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
|
||||
o.renderer.Invalidate()
|
||||
}
|
||||
|
||||
var imageUpdate bytes.Buffer
|
||||
if !o.renderDisabled {
|
||||
if err := o.renderer.Render(&imageUpdate); err != nil {
|
||||
// Companions are decorative. Disable them for this output session after
|
||||
// a renderer failure instead of terminating the interactive shell.
|
||||
o.renderDisabled = true
|
||||
imageUpdate.Reset()
|
||||
}
|
||||
}
|
||||
if imageUpdate.Len() == 0 {
|
||||
return writeChecked(o.output, value)
|
||||
}
|
||||
|
||||
// Bubble Tea encloses supported terminal frames in synchronized-output
|
||||
// markers. Keep the pet placement inside that same transaction so the
|
||||
// terminal never presents the text frame and image movement separately.
|
||||
syncStart := bytes.LastIndex(value, []byte(terminalSyncStart))
|
||||
syncEnd := bytes.LastIndex(value, []byte(terminalSyncEnd))
|
||||
if syncEnd >= 0 && syncEnd > syncStart {
|
||||
var frame bytes.Buffer
|
||||
frame.Grow(len(value) + imageUpdate.Len())
|
||||
frame.Write(value[:syncEnd])
|
||||
frame.Write(imageUpdate.Bytes())
|
||||
frame.Write(value[syncEnd:])
|
||||
written, err := o.output.Write(frame.Bytes())
|
||||
consumed := originalBytesWritten(written, syncEnd, imageUpdate.Len(), len(value))
|
||||
if err == nil && written != frame.Len() {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
return consumed, err
|
||||
}
|
||||
return len(value), nil
|
||||
}
|
||||
if syncStart > syncEnd {
|
||||
written, writeErr := writeChecked(o.output, value)
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
_, imageErr := writeChecked(o.output, imageUpdate.Bytes())
|
||||
return written, imageErr
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, writeErr := writeChecked(o.output, value)
|
||||
if writeErr == nil {
|
||||
_, writeErr = writeChecked(o.output, imageUpdate.Bytes())
|
||||
}
|
||||
_, endErr := io.WriteString(o.output, terminalSyncEnd)
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
return written, endErr
|
||||
}
|
||||
|
||||
func writeChecked(writer io.Writer, value []byte) (int, error) {
|
||||
written, err := writer.Write(value)
|
||||
if err == nil && written != len(value) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
// originalBytesWritten maps progress through an expanded synchronized frame
|
||||
// back to bytes consumed from the caller's original buffer. Bytes belonging
|
||||
// to the injected image update must never be reported as caller bytes.
|
||||
func originalBytesWritten(written, prefixLength, injectedLength, originalLength int) int {
|
||||
if written <= 0 {
|
||||
return 0
|
||||
}
|
||||
if written <= prefixLength {
|
||||
return written
|
||||
}
|
||||
consumed := prefixLength
|
||||
suffixWritten := written - prefixLength - injectedLength
|
||||
if suffixWritten > 0 {
|
||||
consumed += min(suffixWritten, originalLength-prefixLength)
|
||||
}
|
||||
return min(consumed, originalLength)
|
||||
}
|
||||
|
||||
func (o *petImageOutput) writeImageUpdate(render func(io.Writer) error) error {
|
||||
var update bytes.Buffer
|
||||
if err := render(&update); err != nil {
|
||||
return err
|
||||
}
|
||||
if update.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := o.output.Write(update.Bytes())
|
||||
_, endErr := io.WriteString(o.output, terminalSyncEnd)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return endErr
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Fd() uintptr {
|
||||
return o.output.Fd()
|
||||
}
|
||||
|
||||
func (o *petImageOutput) clearImage() error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.writeImageUpdate(func(writer io.Writer) error {
|
||||
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := o.renderer.Clear(writer); err != nil {
|
||||
return err
|
||||
}
|
||||
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type shortTerminalOutput struct {
|
||||
limit int
|
||||
err error
|
||||
}
|
||||
|
||||
func (o *shortTerminalOutput) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (o *shortTerminalOutput) Close() error { return nil }
|
||||
func (o *shortTerminalOutput) Fd() uintptr { return 0 }
|
||||
func (o *shortTerminalOutput) Write(value []byte) (int, error) {
|
||||
return min(o.limit, len(value)), o.err
|
||||
}
|
||||
|
||||
func TestPetImageOutputAppendsScheduledImageAfterFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("bubbletea-frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if !strings.HasPrefix(got, terminalSyncStart+"bubbletea-frame") || !strings.Contains(got, "_Ga=T,t=d,f=100,c=4,r=3") {
|
||||
t.Fatalf("output did not append image after frame: %q", got)
|
||||
}
|
||||
syncStart := strings.Index(got, "\x1b[?2026h")
|
||||
frameAt := strings.Index(got, "bubbletea-frame")
|
||||
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
|
||||
syncEnd := strings.LastIndex(got, "\x1b[?2026l")
|
||||
if syncStart < 0 || frameAt < syncStart || imageAt < frameAt || syncEnd < imageAt {
|
||||
t.Fatalf("frame and image update were not synchronized together: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputKeepsImageInsideBubbleTeaSynchronizedFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
|
||||
if _, err := output.Write([]byte(bubbleTeaFrame)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if count := strings.Count(got, terminalSyncStart); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
|
||||
}
|
||||
if count := strings.Count(got, terminalSyncEnd); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
|
||||
}
|
||||
frameAt := strings.Index(got, "streamed-text-frame")
|
||||
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if frameAt < 0 || imageAt < frameAt || syncEnd < imageAt {
|
||||
t.Fatalf("pet update was presented outside the Bubble Tea frame: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputMapsPartialSynchronizedWriteToOriginalBytes(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
|
||||
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
|
||||
wantErr := errors.New("partial terminal write")
|
||||
sink := &shortTerminalOutput{limit: prefixLength + 3, err: wantErr}
|
||||
|
||||
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Write error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if written != prefixLength {
|
||||
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputReportsNilErrorShortWrite(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
|
||||
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
|
||||
sink := &shortTerminalOutput{limit: prefixLength + 3}
|
||||
|
||||
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
|
||||
if !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("Write error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
if written != prefixLength {
|
||||
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputKeepsDraggedPlacementInsideStreamingFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
|
||||
renderer.Set(&draw)
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draw.X = 8
|
||||
draw.Y = 6
|
||||
renderer.Set(&draw)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "streamed-delta" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data[start:])
|
||||
if count := strings.Count(got, terminalSyncStart); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
|
||||
}
|
||||
if count := strings.Count(got, terminalSyncEnd); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
|
||||
}
|
||||
textAt := strings.Index(got, "streamed-delta")
|
||||
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if textAt < 0 || moveAt < textAt || syncEnd < moveAt {
|
||||
t.Fatalf("dragged pet placement was presented outside the streaming frame: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "_Ga=T") {
|
||||
t.Fatalf("dragging retransmitted image data instead of moving its placement: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputFlushesMovedPlacementWithoutTextChange(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
|
||||
renderer.Set(&draw)
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draw.X = 8
|
||||
draw.Y = 6
|
||||
renderer.Set(&draw)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data[start:])
|
||||
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if moveAt < 0 || syncEnd < moveAt {
|
||||
t.Fatalf("idle drag flush did not place the pet inside its empty frame: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "_Ga=T") {
|
||||
t.Fatalf("idle drag flush retransmitted image data: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputRetransmitsAfterFullScreenClear(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clearFrame := terminalSyncStart + ansi.EraseEntireScreen + "resized-frame" + terminalSyncEnd
|
||||
if _, err := output.Write([]byte(clearFrame)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data[start:])
|
||||
clearAt := strings.Index(got, ansi.EraseEntireScreen)
|
||||
transmitAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if clearAt < 0 || transmitAt < clearAt || syncEnd < transmitAt {
|
||||
t.Fatalf("pet was not restored inside the full-screen redraw: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputClearsKittyImageAfterLeavingAltScreen(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
deleteAt := strings.LastIndex(got, "_Ga=d,d=I,i=7,q=2")
|
||||
exitAt := strings.LastIndex(got, "\x1b[?1049l")
|
||||
if deleteAt < 0 || exitAt < 0 || deleteAt < exitAt {
|
||||
t.Fatalf("Kitty image was not cleared after alt-screen exit: %q", got)
|
||||
}
|
||||
syncStart := strings.LastIndex(got[:deleteAt], "\x1b[?2026h")
|
||||
syncEnd := strings.Index(got[deleteAt:], "\x1b[?2026l")
|
||||
if syncStart < exitAt || syncEnd < 0 {
|
||||
t.Fatalf("Kitty image cleanup was not synchronized after alt-screen exit: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputFinalCleanupRepeatsAllKittyDeletes(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupStart, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := output.clearImage(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanup := string(data[cleanupStart:])
|
||||
if !strings.Contains(cleanup, ansi.ResetModeMouseExtSgrPixel) {
|
||||
t.Fatalf("final cleanup did not reset pixel mouse mode: %q", cleanup)
|
||||
}
|
||||
for _, id := range []uint32{petAmbientImageID, petPreviewImageID} {
|
||||
want := fmt.Sprintf("_Ga=d,d=I,i=%d,q=2", id)
|
||||
if !strings.Contains(cleanup, want) {
|
||||
t.Errorf("final cleanup did not repeat Kitty delete for image %d: %q", id, cleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputClearsSixelBeforeLeavingAltScreen(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3, HeightPixels: 12})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
clearAt := strings.LastIndex(got, "\x1b[4;3H ")
|
||||
exitAt := strings.LastIndex(got, "\x1b[?1049l")
|
||||
if clearAt < 0 || exitAt < 0 || clearAt > exitAt {
|
||||
t.Fatalf("Sixel cell area was not cleared before alt-screen exit: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputDoesNotCloseAnExistingSynchronizedFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
if _, err := newPetImageOutput(file, renderer).Write([]byte(terminalSyncStart + "partial-frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if strings.Count(got, terminalSyncStart) != 1 || strings.Contains(got, terminalSyncEnd) {
|
||||
t.Fatalf("pet output changed caller-owned synchronized frame boundaries: %q", got)
|
||||
}
|
||||
if strings.Index(got, "_Ga=T") < strings.Index(got, "partial-frame") {
|
||||
t.Fatalf("pet image was not appended to the open frame: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputReportsUnsynchronizedShortWrite(t *testing.T) {
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
sink := &shortTerminalOutput{limit: len(terminalSyncStart)}
|
||||
written, err := newPetImageOutput(sink, renderer).Write([]byte("long-frame-payload"))
|
||||
if !errors.Is(err, io.ErrShortWrite) || written != len(terminalSyncStart) {
|
||||
t.Fatalf("Write() = %d, %v; want %d, %v", written, err, len(terminalSyncStart), io.ErrShortWrite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputKeepsTextAliveAfterRendererFailure(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKittyLocalFile})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
for _, frame := range []string{"first-frame", "second-frame"} {
|
||||
if written, err := output.Write([]byte(frame)); err != nil || written != len(frame) {
|
||||
t.Fatalf("Write(%q) = %d, %v", frame, written, err)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != "first-framesecond-frame" {
|
||||
t.Fatalf("text output after renderer failure = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputSerializesWriteAndCleanup(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
var wait sync.WaitGroup
|
||||
errorsSeen := make(chan error, 9)
|
||||
for range 8 {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
_, err := output.Write([]byte("frame"))
|
||||
errorsSeen <- err
|
||||
}()
|
||||
}
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
errorsSeen <- output.clearImage()
|
||||
}()
|
||||
wait.Wait()
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
// The erase rectangle has to describe what was PAINTED, not the reserved area.
|
||||
//
|
||||
// Measured on Windows Terminal 1.24, which answers CSI 16 t with a 20x10 cell.
|
||||
// A companion is rendered at preferredHeight (75px), so it covers
|
||||
// ceil(75/20) = 4 rows, while petImageRows is 5. The erase used the constant and
|
||||
// blanked a fifth row of live interface every time the companion moved, which is
|
||||
// the broken border and the eaten characters reported on Windows.
|
||||
//
|
||||
// Sixel is the only protocol that erases by writing over cells, so it is the
|
||||
// only one that needs this; the Kitty case below pins that it is left alone.
|
||||
func TestSixelEraseFootprintMatchesWhatWasPainted(t *testing.T) {
|
||||
m := model{
|
||||
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}),
|
||||
petCellPixelWidth: 10,
|
||||
petCellPixelHeight: 20,
|
||||
}
|
||||
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
|
||||
if rows != 4 {
|
||||
t.Errorf("rows = %d, want 4 for a 75px sprite in a 20px cell; erasing %d would blank live interface", rows, petImageRows)
|
||||
}
|
||||
if rows > petImageRows || columns > petImageColumns {
|
||||
t.Errorf("footprint %dx%d exceeds the reserved %dx%d area", columns, rows, petImageColumns, petImageRows)
|
||||
}
|
||||
}
|
||||
|
||||
// Kitty passes Columns/Rows to the terminal as the placement REQUEST and the
|
||||
// terminal owns the region, so the constants are correct there and must not be
|
||||
// recomputed.
|
||||
func TestKittyKeepsTheRequestedPlacementSize(t *testing.T) {
|
||||
m := model{
|
||||
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
|
||||
petCellPixelWidth: 10,
|
||||
petCellPixelHeight: 20,
|
||||
}
|
||||
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
|
||||
if columns != petImageColumns || rows != petImageRows {
|
||||
t.Errorf("Kitty placement = %dx%d, want the requested %dx%d", columns, rows, petImageColumns, petImageRows)
|
||||
}
|
||||
}
|
||||
|
||||
// Without metrics there is nothing to compute from, so it must fall back to the
|
||||
// constants rather than to zero, which would erase nothing and leave the
|
||||
// companion smeared across the screen.
|
||||
func TestFootprintFallsBackToTheReservedAreaWithoutMetrics(t *testing.T) {
|
||||
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})}
|
||||
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
|
||||
if columns != petImageColumns || rows != petImageRows {
|
||||
t.Errorf("fallback = %dx%d, want the reserved %dx%d", columns, rows, petImageColumns, petImageRows)
|
||||
}
|
||||
}
|
||||
|
||||
// The request has to be sent for a sixel terminal, or the metrics never arrive
|
||||
// and every computation above silently uses the fallback. This is the actual
|
||||
// defect: the reply was never asked for, not unsupported.
|
||||
func TestCellMetricsAreRequestedForEveryImageProtocol(t *testing.T) {
|
||||
for _, protocol := range []terminalpet.ImageProtocol{
|
||||
terminalpet.ImageProtocolSixel,
|
||||
terminalpet.ImageProtocolKitty,
|
||||
terminalpet.ImageProtocolKittyLocalFile,
|
||||
} {
|
||||
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: protocol})}
|
||||
if !m.petCellMetricsWanted() {
|
||||
t.Errorf("protocol %v does not ask the terminal for its cell size, so the reply never arrives", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+19
-3
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
)
|
||||
|
||||
// pickerKind identifies which command a picker selection feeds back into.
|
||||
@@ -27,6 +28,7 @@ const (
|
||||
pickerSkill
|
||||
pickerSTTModel
|
||||
pickerSTTDownload
|
||||
pickerPet
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -406,8 +408,9 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
|
||||
needOAuth := key == "" && descriptor.OAuth && !descriptor.OAuthMintsKey
|
||||
discover := m.discoverProviderModels
|
||||
if discover == nil {
|
||||
discoveryOptions := m.modelPickerDiscoveryOptions(authed)
|
||||
discover = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, providermodeldiscovery.Options{})
|
||||
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, discoveryOptions)
|
||||
}
|
||||
}
|
||||
providerID := descriptor.ID
|
||||
@@ -425,6 +428,15 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) modelPickerDiscoveryOptions(profile config.ProviderProfile) providermodeldiscovery.Options {
|
||||
oauthResolver, loginKey := providers.OAuthLoginForProfile(profile)
|
||||
return providermodeldiscovery.Options{
|
||||
OAuthResolver: oauthResolver,
|
||||
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
|
||||
UserAgent: m.userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) modelPickerIsLoading() bool {
|
||||
return m.picker != nil && m.picker.kind == pickerModel && m.modelPickerLoading
|
||||
}
|
||||
@@ -1029,10 +1041,14 @@ func (m model) newThemePicker() *commandPicker {
|
||||
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
|
||||
// mutate through m.picker (a pointer) and the global theme, so the value receiver
|
||||
// is fine.
|
||||
func (m model) pickerMoved(delta int) {
|
||||
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
|
||||
if m.picker == nil {
|
||||
return
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(delta)
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
@@ -52,6 +53,40 @@ func TestModelPickerDetectsOllamaCloudFromBaseURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPickerChatGPTDiscoveryUsesMatchingOAuthAccount(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{
|
||||
AccessToken: "live-token",
|
||||
Account: "workspace-77",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("save OAuth token: %v", err)
|
||||
}
|
||||
|
||||
m := model{userAgent: "zero/test"}
|
||||
options := m.modelPickerDiscoveryOptions(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
|
||||
if options.OAuthResolver == nil || options.CodexAccountResolver == nil {
|
||||
t.Fatal("ChatGPT model discovery must carry both bearer and account resolvers")
|
||||
}
|
||||
header, bearer, ok, err := options.OAuthResolver(context.Background(), false)
|
||||
if err != nil || !ok || header != "Authorization" || bearer != "Bearer live-token" {
|
||||
t.Fatalf("discovery bearer = (%q, %q, %v, %v)", header, bearer, ok, err)
|
||||
}
|
||||
account, ok, err := options.CodexAccountResolver(context.Background())
|
||||
if err != nil || !ok || account != "workspace-77" {
|
||||
t.Fatalf("discovery account = (%q, %v, %v)", account, ok, err)
|
||||
}
|
||||
if options.UserAgent != "zero/test" {
|
||||
t.Fatalf("discovery user agent = %q", options.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPickerRefreshesLiveModelsForActiveProvider(t *testing.T) {
|
||||
var captured config.ProviderProfile
|
||||
m := newModel(context.Background(), Options{
|
||||
@@ -1109,6 +1144,42 @@ func TestEffortPickerOpensForModelWithoutEffortControls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortPickerPrefersLiveProviderReasoningLevels(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "future-reasoner"})
|
||||
m.providerProfile = config.ProviderProfile{CatalogID: "test-provider"}
|
||||
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
|
||||
"test-provider": {{
|
||||
ID: "future-reasoner",
|
||||
Reasoning: true,
|
||||
ReasoningEfforts: []string{"low", "high", "xhigh", "max", "ultra", "unknown", "high"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := m.availableReasoningEfforts()
|
||||
want := []modelregistry.ReasoningEffort{
|
||||
modelregistry.ReasoningEffortLow,
|
||||
modelregistry.ReasoningEffortHigh,
|
||||
modelregistry.ReasoningEffortXHigh,
|
||||
modelregistry.ReasoningEffortMax,
|
||||
modelregistry.ReasoningEffortUltra,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("reasoning efforts = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortPickerDoesNotFallbackWhenLiveModelAdvertisesNoSelectableEfforts(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-5.5"})
|
||||
m.providerProfile = config.ProviderProfile{CatalogID: "test-provider"}
|
||||
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
|
||||
"test-provider": {{ID: "gpt-5.5", ReasoningEfforts: []string{"none"}}},
|
||||
}
|
||||
|
||||
if got := m.availableReasoningEfforts(); len(got) != 0 {
|
||||
t.Fatalf("live model with only none exposed efforts %#v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortPickerAutoSelectionKeepsEffortUnset(t *testing.T) {
|
||||
// Picking "auto" on a model without effort controls clears any stale
|
||||
// preference and emits the success status text (handleEffortCommand("auto")).
|
||||
|
||||
+38
-1
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/charmbracelet/x/term"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/peermsg"
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
|
||||
@@ -41,11 +44,13 @@ func Run(ctx context.Context, options Options) int {
|
||||
// text first, keeping order intact.
|
||||
options.RuntimeMessageSink = newTextCoalescer(forward).send
|
||||
options.AltScreen = useAltScreen(options)
|
||||
petRenderer := terminalpet.NewImageRendererWithCache(terminalpet.DetectImageSupport(os.Getenv), terminalPetFrameCache(options))
|
||||
petOutput := newPetImageOutput(os.Stdout, petRenderer)
|
||||
|
||||
programOpts := []tea.ProgramOption{
|
||||
tea.WithContext(ctx),
|
||||
tea.WithInput(os.Stdin),
|
||||
tea.WithOutput(os.Stdout),
|
||||
tea.WithOutput(petOutput),
|
||||
tea.WithFilter(mouseEventFilter()),
|
||||
}
|
||||
// Honor the no-color.org spec ourselves: NO_COLOR set to ANY non-empty value
|
||||
@@ -56,6 +61,7 @@ func Run(ctx context.Context, options Options) int {
|
||||
programOpts = append(programOpts, tea.WithColorProfile(colorprofile.Ascii))
|
||||
}
|
||||
initialModel := newModel(ctx, options)
|
||||
initialModel.petRenderer = petRenderer
|
||||
if initialModel.wantsMouseCapture() {
|
||||
initialModel.mouseCapture = true
|
||||
}
|
||||
@@ -88,6 +94,7 @@ func Run(ctx context.Context, options Options) int {
|
||||
}
|
||||
|
||||
_, runErr := program.Run()
|
||||
clearErr := petOutput.clearImage()
|
||||
var closeErr error
|
||||
if peerStarted {
|
||||
closeErr = options.PeerService.Close()
|
||||
@@ -102,9 +109,39 @@ func Run(ctx context.Context, options Options) int {
|
||||
fmt.Fprintln(os.Stderr, "zero: peer messaging cleanup error:", closeErr)
|
||||
return 1
|
||||
}
|
||||
if clearErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "zero: terminal companion cleanup error:", clearErr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func terminalPetFrameCache(options Options) string {
|
||||
return terminalPetFrameCacheWith(options, os.UserConfigDir, os.UserCacheDir)
|
||||
}
|
||||
|
||||
func terminalPetFrameCacheWith(options Options, userConfigDir, userCacheDir func() (string, error)) string {
|
||||
root := ""
|
||||
if configPath := strings.TrimSpace(options.UserConfigPath); filepath.IsAbs(configPath) {
|
||||
root = filepath.Dir(filepath.Clean(configPath))
|
||||
}
|
||||
if root == "" {
|
||||
configDir, err := userConfigDir()
|
||||
if err == nil && strings.TrimSpace(configDir) != "" {
|
||||
root = filepath.Join(configDir, "zero")
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
cacheDir, err := userCacheDir()
|
||||
if err == nil && strings.TrimSpace(cacheDir) != "" {
|
||||
root = filepath.Join(cacheDir, "zero")
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, "pets", "frame-cache")
|
||||
}
|
||||
|
||||
func useAltScreen(_ Options) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -16,6 +18,60 @@ func TestUseAltScreenForInteractiveChat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalPetFrameCache(t *testing.T) {
|
||||
configRoot := filepath.Join(t.TempDir(), "config-root")
|
||||
cacheRoot := filepath.Join(t.TempDir(), "cache-root")
|
||||
absConfig := filepath.Join(t.TempDir(), "custom", "config.json")
|
||||
configDir := func() (string, error) { return configRoot, nil }
|
||||
cacheDir := func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
options Options
|
||||
want string
|
||||
}{
|
||||
{name: "absolute config", options: Options{UserConfigPath: absConfig}, want: filepath.Join(filepath.Dir(absConfig), "pets", "frame-cache")},
|
||||
{name: "relative config falls back", options: Options{UserConfigPath: "config.json"}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
|
||||
{name: "whitespace config falls back", options: Options{UserConfigPath: " "}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
|
||||
{name: "empty config falls back", options: Options{}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := terminalPetFrameCacheWith(test.options, configDir, cacheDir)
|
||||
if canonicalTestPath(t, got) != canonicalTestPath(t, test.want) {
|
||||
t.Fatalf("terminalPetFrameCacheWith() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unavailable := func() (string, error) { return "", errors.New("unavailable") }
|
||||
blank := func() (string, error) { return "", nil }
|
||||
if got, want := terminalPetFrameCacheWith(Options{}, unavailable, cacheDir), filepath.Join(cacheRoot, "zero", "pets", "frame-cache"); canonicalTestPath(t, got) != canonicalTestPath(t, want) {
|
||||
t.Fatalf("cache fallback = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := terminalPetFrameCacheWith(Options{}, blank, cacheDir), filepath.Join(cacheRoot, "zero", "pets", "frame-cache"); canonicalTestPath(t, got) != canonicalTestPath(t, want) {
|
||||
t.Fatalf("blank config root fallback = %q, want %q", got, want)
|
||||
}
|
||||
if got := terminalPetFrameCacheWith(Options{}, unavailable, unavailable); got != "" {
|
||||
t.Fatalf("unavailable roots returned %q, want empty", got)
|
||||
}
|
||||
if got := terminalPetFrameCacheWith(Options{}, unavailable, blank); got != "" {
|
||||
t.Fatalf("blank cache root returned %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalTestPath(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
// TestRunRejectsNonTTYStdin pins that the interactive shell fails fast with a
|
||||
// non-zero code when stdin is not a terminal, instead of blocking forever in the
|
||||
// Bubble Tea event loop (e.g. `echo "" | zero`). The guard runs before any model
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -84,6 +86,96 @@ func (m model) handleEffortCommand(args string) (model, string) {
|
||||
return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.")
|
||||
}
|
||||
|
||||
func (m model) handleFastCommand(args string) (model, string) {
|
||||
if strings.TrimSpace(args) != "" {
|
||||
return m, m.fastStatusText("Use /fast to toggle fast mode for supported ChatGPT subscription models.")
|
||||
}
|
||||
if m.pending {
|
||||
return m, m.fastStatusText("Finish or stop the current run before changing fast mode.")
|
||||
}
|
||||
if !m.fastModeSupported() {
|
||||
m.serviceTier = ""
|
||||
return m, m.fastStatusText("Fast mode is available only for ChatGPT subscription models that advertise it.")
|
||||
}
|
||||
if m.activeServiceTier() == "priority" {
|
||||
m.serviceTier = ""
|
||||
} else {
|
||||
m.serviceTier = "priority"
|
||||
}
|
||||
return m, m.fastStatusText("Fast mode applies to the next request for this ChatGPT subscription model.")
|
||||
}
|
||||
|
||||
func (m model) fastStatusText(detail string) string {
|
||||
state := "off"
|
||||
if m.activeServiceTier() == "priority" {
|
||||
state = "on"
|
||||
}
|
||||
fields := []commandField{
|
||||
{Key: "fast mode", Value: state},
|
||||
{Key: "model", Value: displayValue(m.modelName, "none")},
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "Use /fast to toggle fast mode for supported ChatGPT subscription models."
|
||||
}
|
||||
return renderCommandCardTranscript(commandCard{
|
||||
Title: "Fast",
|
||||
Summary: []string{"fast mode: " + state},
|
||||
Sections: []commandCardSection{{
|
||||
Title: "State",
|
||||
Fields: fields,
|
||||
Lines: []string{detail},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) activeServiceTier() string {
|
||||
if m.serviceTier == "priority" && m.fastModeSupported() {
|
||||
return "priority"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) fastModeSupported() bool {
|
||||
// Priority is a ChatGPT subscription capability. Never forward it for
|
||||
// metered provider profiles, even if a third-party catalog advertises it.
|
||||
if providercatalog.NormalizeID(m.providerProfile.CatalogID) != "chatgpt" {
|
||||
return false
|
||||
}
|
||||
for _, model := range m.discoveredModelsForActiveProvider() {
|
||||
if !strings.EqualFold(strings.TrimSpace(model.ID), strings.TrimSpace(m.modelName)) {
|
||||
continue
|
||||
}
|
||||
for _, tier := range model.ServiceTiers {
|
||||
if normalizeTUIServiceTier(tier) == "priority" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m model) discoveredModelsForActiveProvider() []providermodeldiscovery.Model {
|
||||
for _, key := range []string{m.providerProfile.CatalogID, m.providerProfile.Name, m.providerName} {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if models := m.modelPickerLiveByProvider[key]; len(models) > 0 {
|
||||
return models
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTUIServiceTier(tier string) string {
|
||||
tier = strings.ToLower(strings.TrimSpace(tier))
|
||||
if tier == "fast" {
|
||||
return "priority"
|
||||
}
|
||||
return tier
|
||||
}
|
||||
|
||||
// effortStatusCard renders the small inline confirmation card shown after a
|
||||
// /effort <value> mutation (set, auto, unknown, unsupported). The body is a
|
||||
// lime-bordered card so the transition from "picker open" -> "card collapsed"
|
||||
@@ -236,6 +328,9 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
|
||||
if strings.TrimSpace(m.modelName) == "" {
|
||||
return nil
|
||||
}
|
||||
if efforts, found := m.discoveredReasoningEfforts(); found {
|
||||
return efforts
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -243,6 +338,30 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
|
||||
return registry.ReasoningEfforts(m.modelName)
|
||||
}
|
||||
|
||||
func (m model) discoveredReasoningEfforts() ([]modelregistry.ReasoningEffort, bool) {
|
||||
return reasoningEffortsFromModels(m.discoveredModelsForActiveProvider(), m.modelName)
|
||||
}
|
||||
|
||||
func reasoningEffortsFromModels(models []providermodeldiscovery.Model, modelName string) ([]modelregistry.ReasoningEffort, bool) {
|
||||
for _, discovered := range models {
|
||||
if !strings.EqualFold(strings.TrimSpace(discovered.ID), strings.TrimSpace(modelName)) {
|
||||
continue
|
||||
}
|
||||
seen := map[modelregistry.ReasoningEffort]bool{}
|
||||
efforts := make([]modelregistry.ReasoningEffort, 0, len(discovered.ReasoningEfforts))
|
||||
for _, raw := range discovered.ReasoningEfforts {
|
||||
effort := modelregistry.ReasoningEffort(strings.ToLower(strings.TrimSpace(raw)))
|
||||
if effort == modelregistry.ReasoningEffortNone || !modelregistry.ValidReasoningEffort(effort) || seen[effort] {
|
||||
continue
|
||||
}
|
||||
seen[effort] = true
|
||||
efforts = append(efforts, effort)
|
||||
}
|
||||
return efforts, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m model) effortDisplay() string {
|
||||
if m.reasoningEffort == "" {
|
||||
return "auto"
|
||||
|
||||
@@ -627,6 +627,41 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoColumnSidebarRemainsFullHeightBesideFooter(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
m.width, m.height = 120, 34
|
||||
m.unpricedTokens = 10000
|
||||
out := plainRender(t, m.twoColumnTranscriptView())
|
||||
lines := strings.Split(out, "\n")
|
||||
|
||||
tokenRow := -1
|
||||
composerTop := -1
|
||||
for index, line := range lines {
|
||||
if strings.Contains(line, "10K tokens") {
|
||||
tokenRow = index
|
||||
}
|
||||
if strings.HasPrefix(line, "╭") {
|
||||
composerTop = index
|
||||
}
|
||||
}
|
||||
if tokenRow != len(lines)-1 {
|
||||
t.Fatalf("sidebar token summary should remain pinned to the bottom, row=%d last=%d\n%s", tokenRow, len(lines)-1, out)
|
||||
}
|
||||
if composerTop < 0 {
|
||||
t.Fatalf("chat composer missing:\n%s", out)
|
||||
}
|
||||
composerRunes := []rune(lines[composerTop])
|
||||
if len(composerRunes) != m.width || composerRunes[m.chatColumnWidth()-1] != '╮' {
|
||||
t.Fatalf("composer should retain the chat-column width, got %q", lines[composerTop])
|
||||
}
|
||||
for index, line := range lines {
|
||||
runes := []rune(line)
|
||||
if len(runes) != m.width || runes[m.chatColumnWidth()+1] != '│' {
|
||||
t.Fatalf("sidebar divider ended early on row %d: %q", index, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stripSidebar joins sidebar lines and strips ANSI for content assertions.
|
||||
func stripSidebar(lines []string) string {
|
||||
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
|
||||
|
||||
+15
-5
@@ -172,18 +172,22 @@ func (m model) composerDividerLine(width int) string {
|
||||
// footer for run-state), so they're not duplicated on this rule.
|
||||
meta := zeroTheme.muted.Render(model)
|
||||
metaWidth := lipgloss.Width(meta)
|
||||
reserved := m.petComposerReservedColumns(width)
|
||||
availableWidth := width - reserved
|
||||
if width < 8 {
|
||||
return zeroTheme.lineStrong.Render(strings.Repeat("─", width))
|
||||
}
|
||||
if width < metaWidth+4 {
|
||||
return zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", width-2) + "╯")
|
||||
if availableWidth < metaWidth+4 {
|
||||
line := zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", availableWidth-2) + "╯")
|
||||
return line + strings.Repeat(" ", reserved)
|
||||
}
|
||||
rule := strings.Repeat("─", width-metaWidth-4)
|
||||
return zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
|
||||
rule := strings.Repeat("─", availableWidth-metaWidth-4)
|
||||
line := zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
|
||||
return line + strings.Repeat(" ", reserved)
|
||||
}
|
||||
|
||||
// statusLine renders the bottom readout as ` │ `-separated groups: the run-state
|
||||
// chip (permission mode + effort) on the left, a flexible gap, then the
|
||||
// chip (permission mode + effort/fast tier) on the left, a flexible gap, then the
|
||||
// context-fill gauge and token/cost usage on the right. The provider lives in the
|
||||
// title bar and is NOT duplicated here. Groups drop with the width tier.
|
||||
func (m model) statusLine(width int) string {
|
||||
@@ -221,6 +225,9 @@ func (m model) statusLine(width int) string {
|
||||
if m.reasoningEffort != "" {
|
||||
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
|
||||
}
|
||||
if m.activeServiceTier() == "priority" {
|
||||
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("fast")
|
||||
}
|
||||
if m.exitConfirmActive {
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
} else if m.cancelConfirmActive {
|
||||
@@ -757,6 +764,9 @@ func (m model) pickerOverlay(width int) string {
|
||||
if m.picker.kind == pickerModel {
|
||||
return m.modelPickerOverlay(width)
|
||||
}
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.petPickerOverlay(width)
|
||||
}
|
||||
overlayWidth := minInt(width, pickerOverlayMaxWidth)
|
||||
if overlayWidth < pickerOverlayMinWidth {
|
||||
overlayWidth = width
|
||||
|
||||
+123
-31
@@ -2,6 +2,7 @@ package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,6 +16,15 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/release"
|
||||
)
|
||||
|
||||
// ErrTargetPossiblyTampered reports that an executable path may hold content
|
||||
// this updater could not verify, because a promotion failed and the original
|
||||
// could not be restored over it. Only the Windows promotion path produces it
|
||||
// today (see replace_windows.go for why that platform has the gap and the POSIX
|
||||
// descriptor-bound path does not), but it is declared here so callers on every
|
||||
// platform can test for it without build tags — the helper-refresh loop below
|
||||
// must fail the apply on it rather than downgrade it to a warning.
|
||||
var ErrTargetPossiblyTampered = errors.New("target executable path may hold unverified content after a failed update")
|
||||
|
||||
// DefaultDownloadTimeout bounds the archive/checksum download phase of a
|
||||
// standalone Apply, separately from Options.Timeout (which only covers the
|
||||
// small release-metadata check), so a stalled connection can't hang forever.
|
||||
@@ -36,6 +46,7 @@ type ApplyResult struct {
|
||||
var (
|
||||
windowsOptionalBinaries = []string{"zero-windows-command-runner.exe", "zero-windows-sandbox-setup.exe"}
|
||||
linuxOptionalBinaries = []string{"zero-linux-sandbox", "zero-seccomp"}
|
||||
currentExecutable = os.Executable
|
||||
)
|
||||
|
||||
// Apply checks for an update and, if one is available, installs it: via
|
||||
@@ -47,25 +58,23 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
executablePath, err := currentExecutable()
|
||||
if err != nil {
|
||||
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
|
||||
executablePath = resolved
|
||||
}
|
||||
// Best-effort: remove a "<binary>.old" left behind by a previous Windows
|
||||
// replaceBinary call now that enough time (a whole separate invocation)
|
||||
// has passed for the old process to have released the file. Runs
|
||||
// regardless of whether an update is available, so it isn't stuck waiting
|
||||
// on a future upgrade that may never come.
|
||||
CleanupStaleBinary(executablePath)
|
||||
|
||||
method := DetectInstallMethod(executablePath)
|
||||
if method != InstallMethodNpm {
|
||||
if err := preflightRecoveryState(executablePath); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
}
|
||||
if !checkResult.UpdateAvailable {
|
||||
return ApplyResult{Result: checkResult, Message: "already up to date"}, nil
|
||||
}
|
||||
|
||||
method := DetectInstallMethod(executablePath)
|
||||
switch method {
|
||||
case InstallMethodNpm:
|
||||
if err := applyNpmUpdate(ctx); err != nil {
|
||||
@@ -179,10 +188,6 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
|
||||
}
|
||||
|
||||
targetDir := filepath.Dir(executablePath)
|
||||
if err := installBinary(newBinaryPath, executablePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
for _, name := range optionalBinaries {
|
||||
source, err := findByBasename(extractDir, name)
|
||||
@@ -194,9 +199,29 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
|
||||
continue // only refresh helpers this install already has
|
||||
}
|
||||
if err := installBinary(source, destPath); err != nil {
|
||||
// An optional helper that simply fails to refresh is a warning: the
|
||||
// sandbox degrades gracefully without a newer one, and aborting the
|
||||
// whole update over it would be worse than the stale helper.
|
||||
//
|
||||
// Possible tampering is not that. It means this helper's path may now
|
||||
// hold content the updater could not verify, and helpers are resolved
|
||||
// from the install directory and EXECUTED by the sandbox runner — so
|
||||
// reporting Applied: true and exiting 0 would hand the operator a
|
||||
// success while a sibling executable is suspect. Fail the apply and
|
||||
// keep errors.Is intact, exactly as the main binary does.
|
||||
if errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
return nil, fmt.Errorf("update helper %s: %w", name, err)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("failed to update helper %s: %v", name, err))
|
||||
}
|
||||
}
|
||||
// Refresh helpers before the main executable. If a helper path may have
|
||||
// been tampered with, the running main binary must remain on the old version
|
||||
// so the next invocation still sees this release as available and retries
|
||||
// the helper instead of returning "already up to date".
|
||||
if err := installBinary(newBinaryPath, executablePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
@@ -218,23 +243,79 @@ func verifyArchiveChecksum(checksumPath string, expectedArchiveName string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// installBinary stages sourcePath next to targetPath (same directory, so the
|
||||
// final rename is atomic/same-filesystem) and then swaps it into place.
|
||||
// installBinary stages sourcePath next to targetPath (same filesystem, so the
|
||||
// final rename is atomic) and then swaps it into place.
|
||||
func installBinary(sourcePath string, targetPath string) error {
|
||||
stagedPath := targetPath + ".new"
|
||||
if err := copyFile(sourcePath, stagedPath); err != nil {
|
||||
staged, err := stageBinary(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Remove(stagedPath)
|
||||
}()
|
||||
if err := replaceBinary(targetPath, stagedPath); err != nil {
|
||||
// Registered before the promotion attempt and reached on every failure path,
|
||||
// including a mid-write copy error: each attempt now stages under a fresh
|
||||
// random name, so a leaked partial file is never reused by the next attempt
|
||||
// and would otherwise accumulate release-sized garbage in the install
|
||||
// directory. Unverifiable leftovers from a hard crash are preserved rather
|
||||
// than removed based only on their public filename shape.
|
||||
defer staged.discard()
|
||||
if err := staged.promote(targetPath); err != nil {
|
||||
return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(sourcePath string, destPath string) (retErr error) {
|
||||
// stagedBinary is a freshly created staging object holding the verified release
|
||||
// bytes, together with the handle it was created through. Promotion is bound to
|
||||
// that object rather than to the staging PATHNAME: under the threat model of
|
||||
// #742 a lower-privileged principal that can write in the installation
|
||||
// directory may replace a sibling entry, so re-resolving the staging path at
|
||||
// swap time would let it substitute its own file into the executable path after
|
||||
// the verified bytes were written. Each platform closes that handoff with its
|
||||
// own primitive — see the promote implementations.
|
||||
type stagedBinary struct {
|
||||
file *os.File
|
||||
path string
|
||||
// dir is a private staging directory that must be removed with the file
|
||||
// (POSIX). Empty on Windows, which binds the swap to the handle instead.
|
||||
dir string
|
||||
// dirHandle is an open descriptor on dir (POSIX only), bound to that
|
||||
// directory's inode rather than its current pathname. promote renames the
|
||||
// staged file through it (see stage_other.go) so a principal who can write
|
||||
// in the installation directory cannot redirect the final rename by
|
||||
// renaming dir aside and recreating a look-alike directory at the same
|
||||
// path between the identity check and the rename — a pathname lookup at
|
||||
// that point would resolve through the impostor instead. nil on Windows.
|
||||
dirHandle *os.File
|
||||
// parentHandle is the descriptor-bound parent of dir (POSIX only).
|
||||
parentHandle *os.File
|
||||
// promoted records that path now IS the installed binary, so discard must
|
||||
// not delete it.
|
||||
promoted bool
|
||||
}
|
||||
|
||||
// stageBinary creates the staging object for targetPath and writes sourcePath's
|
||||
// bytes into it through the handle it was created with, leaving that handle open
|
||||
// for promote.
|
||||
//
|
||||
// It is a package var so a test can force a staging failure on every platform:
|
||||
// the staging location is created exclusively under a name that cannot be known
|
||||
// in advance, which is exactly what stops a test (like an attacker) from
|
||||
// occupying it from outside.
|
||||
var stageBinary = func(sourcePath string, targetPath string) (*stagedBinary, error) {
|
||||
staged, err := createStagedBinary(targetPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := staged.copyFrom(sourcePath); err != nil {
|
||||
staged.discard()
|
||||
return nil, err
|
||||
}
|
||||
return staged, nil
|
||||
}
|
||||
|
||||
// copyFrom writes sourcePath into the staged file through the handle
|
||||
// createStagedBinary opened. It never reopens by pathname, so the bytes cannot
|
||||
// land anywhere other than the object that was exclusively created.
|
||||
func (staged *stagedBinary) copyFrom(sourcePath string) error {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -242,17 +323,28 @@ func copyFile(sourcePath string, destPath string) (retErr error) {
|
||||
defer func() {
|
||||
_ = source.Close()
|
||||
}()
|
||||
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
if _, err := io.Copy(staged.file, source); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := dest.Close(); closeErr != nil && retErr == nil {
|
||||
retErr = closeErr
|
||||
}
|
||||
}()
|
||||
_, retErr = io.Copy(dest, source)
|
||||
return retErr
|
||||
return staged.file.Sync()
|
||||
}
|
||||
|
||||
// discard closes the handle(s) and removes what it created, unless the object
|
||||
// was already promoted into the executable path.
|
||||
func (staged *stagedBinary) discard() {
|
||||
if staged == nil {
|
||||
return
|
||||
}
|
||||
// Removal is bound to the staged object before the handle is released
|
||||
// wherever the platform allows it (Windows, via delete-on-close). Once the
|
||||
// handle is gone the staging pathname can be re-resolved, and under the
|
||||
// writable-install-directory threat model that entry may by then name a file
|
||||
// this updater never created.
|
||||
staged.discardOpenObject()
|
||||
if staged.file != nil {
|
||||
_ = staged.file.Close()
|
||||
}
|
||||
staged.discardPaths()
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, url string, destPath string) error {
|
||||
|
||||
@@ -2,6 +2,8 @@ package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -138,9 +140,12 @@ func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
|
||||
if entries, err := os.ReadDir(installDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
// On Windows, replaceBinary leaves "<name>.old" behind (the running
|
||||
// binary is renamed aside, not deleted) for later best-effort cleanup.
|
||||
if name == binaryName || (optionalName != "" && name == optionalName) || name == binaryName+".old" || (optionalName != "" && name == optionalName+".old") {
|
||||
// On Windows, replacement leaves a namespaced recovery copy of each
|
||||
// replaced binary for identity-bound cleanup by a later update.
|
||||
windowsRecovery := runtime.GOOS == "windows" && strings.HasSuffix(name, ".old") &&
|
||||
(strings.HasPrefix(name, binaryName+".zero-update-") ||
|
||||
optionalName != "" && strings.HasPrefix(name, optionalName+".zero-update-"))
|
||||
if name == binaryName || (optionalName != "" && name == optionalName) || windowsRecovery {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("unexpected extra file left in install dir: %s", name)
|
||||
@@ -169,11 +174,11 @@ func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
|
||||
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile helper: %v", err)
|
||||
}
|
||||
// Force installBinary's staging copy to fail by occupying its staged
|
||||
// "<helper>.new" path with a directory instead of a file.
|
||||
if err := os.MkdirAll(existingHelperPath+".new", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll staged path: %v", err)
|
||||
}
|
||||
// Force the helper's staging to fail. It cannot be provoked from outside any
|
||||
// more: the staging location is created exclusively under a name (POSIX: a
|
||||
// private directory) that nothing else can predict or occupy, which is the
|
||||
// point of the fix. So fail it through the seam instead.
|
||||
stubStageBinaryFailure(t, existingHelperPath, errors.New("staging is unavailable in this test"))
|
||||
|
||||
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
|
||||
archiveDir := t.TempDir()
|
||||
@@ -241,6 +246,98 @@ func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStandaloneUpdateFailsWhenHelperRefreshReportsTampering covers
|
||||
// jatmn's #751 P2. An optional helper that merely fails to refresh stays a
|
||||
// warning — the sandbox degrades fine on a stale helper. Possible tampering is
|
||||
// different in kind: the helper's path may now hold content the updater could
|
||||
// not verify, and helpers are resolved from the install directory and executed
|
||||
// by the sandbox runner, so reporting Applied: true and exiting 0 would hand
|
||||
// the operator a success while a sibling executable is suspect.
|
||||
func TestApplyStandaloneUpdateFailsWhenHelperRefreshReportsTampering(t *testing.T) {
|
||||
binaryName := "zero"
|
||||
optionalName := "zero-seccomp"
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
binaryName = "zero.exe"
|
||||
optionalName = "zero-windows-command-runner.exe"
|
||||
case "darwin":
|
||||
t.Skip("macOS ships no optional helper binaries to refresh")
|
||||
}
|
||||
|
||||
installDir := t.TempDir()
|
||||
executablePath := filepath.Join(installDir, binaryName)
|
||||
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile executable: %v", err)
|
||||
}
|
||||
existingHelperPath := filepath.Join(installDir, optionalName)
|
||||
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile helper: %v", err)
|
||||
}
|
||||
stubStageBinaryFailure(t, existingHelperPath, fmt.Errorf("promote: %w", ErrTargetPossiblyTampered))
|
||||
|
||||
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
|
||||
archiveDir := t.TempDir()
|
||||
archivePath := filepath.Join(archiveDir, archiveName)
|
||||
writeTestTarGz(t, archivePath, map[string]string{
|
||||
"zero": "new-binary",
|
||||
"zero.exe": "new-binary-exe",
|
||||
"zero-seccomp": "new-helper",
|
||||
"zero-windows-command-runner.exe": "new-helper-exe",
|
||||
})
|
||||
checksum, err := release.SHA256File(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("SHA256File: %v", err)
|
||||
}
|
||||
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatSHA256Checksum: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/" + archiveName:
|
||||
http.ServeFile(w, r, archivePath)
|
||||
case "/" + archiveName + ".sha256":
|
||||
_, _ = w.Write([]byte(checksumText))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result := Result{
|
||||
LatestVersion: "0.2.0",
|
||||
ReleaseAsset: AssetCheck{
|
||||
Platform: "linux",
|
||||
Arch: "x64",
|
||||
ArchiveName: archiveName,
|
||||
ArchiveURL: server.URL + "/" + archiveName,
|
||||
ChecksumName: archiveName + ".sha256",
|
||||
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
|
||||
ArchiveFound: true,
|
||||
ChecksumFound: true,
|
||||
Verified: true,
|
||||
},
|
||||
}
|
||||
|
||||
warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("applyStandaloneUpdate error = %v, want it to wrap ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), optionalName) {
|
||||
t.Fatalf("error = %v, want it to name the helper %q", err, optionalName)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("tampering must not be reported as a warning: %v", warnings)
|
||||
}
|
||||
mainData, readErr := os.ReadFile(executablePath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile main binary: %v", readErr)
|
||||
}
|
||||
if string(mainData) != "old-binary" {
|
||||
t.Fatalf("main binary = %q, want old-binary so a retry still sees the update", mainData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
|
||||
binaryName := "zero"
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyAlreadyCurrentRefusesUnresolvedRecoveryState(t *testing.T) {
|
||||
for _, setup := range []struct {
|
||||
name string
|
||||
run func(string) error
|
||||
}{
|
||||
{"relocated recovery", func(target string) error {
|
||||
return os.WriteFile(target+".old.deadbeef.recovery", []byte("known-good"), 0o600)
|
||||
}},
|
||||
{"marked recovery", func(target string) error {
|
||||
if err := os.WriteFile(target+".old", []byte("known-good"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target+".old.keep", []byte("preserve"), 0o600)
|
||||
}},
|
||||
{"missing target", func(target string) error {
|
||||
if err := os.Remove(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target+".old", []byte("known-good"), 0o600)
|
||||
}},
|
||||
} {
|
||||
t.Run(setup.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setup.run(targetPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalExecutable := currentExecutable
|
||||
currentExecutable = func() (string, error) { return targetPath, nil }
|
||||
defer func() { currentExecutable = originalExecutable }()
|
||||
|
||||
_, err := Apply(context.Background(), Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Fetch: func(context.Context, string) (Release, error) {
|
||||
return releaseForTarget(t, "v1.0.0", "windows", "amd64"), nil
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("Apply error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
func preflightRecoveryState(string) error { return nil }
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import "os"
|
||||
|
||||
// replaceBinary installs newPath over targetPath. On POSIX systems renaming
|
||||
// over a running executable is safe: the process currently executing it keeps
|
||||
// its open inode, and the rename is atomic within the same filesystem.
|
||||
func replaceBinary(targetPath string, newPath string) error {
|
||||
if err := os.Chmod(newPath, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(newPath, targetPath)
|
||||
}
|
||||
|
||||
// CleanupStaleBinary is a no-op outside Windows, which is the only platform
|
||||
// that requires renaming a running binary aside instead of replacing it directly.
|
||||
func CleanupStaleBinary(targetPath string) {}
|
||||
@@ -3,9 +3,18 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,33 +22,16 @@ const (
|
||||
restoreRenameRetryDelay = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// replaceBinary installs newPath over targetPath. Windows will not let a
|
||||
// running executable be overwritten or deleted directly, but NTFS does allow
|
||||
// renaming it aside — the same trick already used for locked config files in
|
||||
// internal/cli/mcp_config.go's replaceMCPWritableConfigFile.
|
||||
func replaceBinary(targetPath string, newPath string) error {
|
||||
oldPath := targetPath + ".old"
|
||||
_ = os.Remove(oldPath) // best-effort cleanup of a leftover from a previous upgrade
|
||||
if err := os.Rename(targetPath, oldPath); err != nil {
|
||||
return fmt.Errorf("rename running binary aside: %w", err)
|
||||
}
|
||||
if err := os.Rename(newPath, targetPath); err != nil {
|
||||
// Retry the restore: a transient Windows file lock (antivirus/indexer
|
||||
// scanning the just-renamed file, a lingering handle) can make a rename
|
||||
// fail momentarily, and here failure means targetPath is left missing
|
||||
// entirely rather than merely stale — worth a short retry to avoid that.
|
||||
if restoreErr := renameWithRetry(oldPath, targetPath); restoreErr != nil {
|
||||
return fmt.Errorf("install new binary: %w; additionally failed to restore the original binary: %v (original preserved at %s)", err, restoreErr, oldPath)
|
||||
}
|
||||
return fmt.Errorf("install new binary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameWithRetry(oldPath string, newPath string) error {
|
||||
// renameOpenFileWithRetry renames the object file refers to onto newPath,
|
||||
// retrying while something transient (an on-access scanner, a stale handle from
|
||||
// the previous process image) still occupies the destination. The retry is on
|
||||
// the handle-bound rename rather than a pathname rename: the destination is in a
|
||||
// directory the threat principal can write, so re-resolving the SOURCE pathname
|
||||
// between attempts would let a substitute be restored instead.
|
||||
func renameOpenFileWithRetry(file *os.File, newPath string) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < restoreRenameRetryAttempts; attempt++ {
|
||||
if err := os.Rename(oldPath, newPath); err == nil {
|
||||
if err := renameRecoveryFileByHandle(file, newPath); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
@@ -49,9 +41,489 @@ func renameWithRetry(oldPath string, newPath string) error {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// CleanupStaleBinary best-effort removes a "<path>.old" file left behind by a
|
||||
// previous replaceBinary call once the old process holding it has exited.
|
||||
// Callers should invoke this once at startup for the current executable.
|
||||
func CleanupStaleBinary(targetPath string) {
|
||||
_ = os.Remove(targetPath + ".old")
|
||||
// This file is where ErrTargetPossiblyTampered (declared in apply.go, so
|
||||
// cross-platform callers can test for it) is produced. It is wrapped into the
|
||||
// error promote returns when a promotion attempt fails AND restoring the
|
||||
// original binary to targetPath also fails. That combination means a principal
|
||||
// who can write in the installation directory occupied targetPath in the gap
|
||||
// the updater opened by renaming the running binary aside, and Windows would
|
||||
// not let anything — including the restore — replace it:
|
||||
// MOVEFILE_REPLACE_EXISTING cannot force past another handle's share-mode lock.
|
||||
// This is not an ordinary failed update (the previous version simply staying in
|
||||
// place); the executable path may now hold attacker-controlled bytes, so a
|
||||
// caller must surface it as a security-relevant condition, not the same "try
|
||||
// again later" failure as a stalled download.
|
||||
//
|
||||
// It is also returned by promote when a PREVIOUS run left that state behind and
|
||||
// nobody has resolved it yet — see the refusal there.
|
||||
|
||||
// restoreOriginalBinary moves the object file refers to — the binary promote
|
||||
// moved aside from targetPath, held open since before that rename — back onto
|
||||
// targetPath after a failed promotion. Everything here is bound to that handle:
|
||||
// oldPath is only ever used to NAME the object for the operator and to site the
|
||||
// recovery marker, never re-resolved to decide what gets restored. A failed
|
||||
// immediate restore is surfaced; the aside copy must not be queued as a reboot
|
||||
// source because its pathname can be replaced before reboot under the
|
||||
// writable-directory threat model.
|
||||
func restoreOriginalBinary(file *os.File, oldPath string, targetPath string) error {
|
||||
err := renameOpenFileWithRetry(file, targetPath)
|
||||
if err == nil {
|
||||
err = verifyPromotedTarget(file, targetPath)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// The error this produces tells the operator their original is preserved at
|
||||
// oldPath. Record that so a later promotion refuses to treat the recovery
|
||||
// copy as the ordinary destination for another aside rename.
|
||||
if markErr := markOldBinaryPreserved(oldPath); markErr != nil {
|
||||
// The marker could not be established, so nothing on disk identifies
|
||||
// oldPath as the recovery copy. Move it to a distinct name and report that
|
||||
// authoritative location to the operator.
|
||||
if kept, keepErr := keepUnmarkedRecoveryCopy(file, oldPath); keepErr == nil {
|
||||
return fmt.Errorf(
|
||||
"%w: %v (the recovery marker could not be written: %v; the last binary this updater verified was moved to the distinct recovery path %s)",
|
||||
ErrTargetPossiblyTampered, err, markErr, kept,
|
||||
)
|
||||
} else if kept != "" {
|
||||
// The move succeeded but the post-move verification did not, so
|
||||
// oldPath is already vacated — point at kept, the path the
|
||||
// bytes actually landed at, not the path that no longer holds them.
|
||||
return fmt.Errorf(
|
||||
"%w: %v (the recovery marker could not be written: %v; the last binary this updater verified was moved to %s but could not be verified there: %v)",
|
||||
ErrTargetPossiblyTampered, err, markErr, kept, keepErr,
|
||||
)
|
||||
} else {
|
||||
return fmt.Errorf(
|
||||
"%w: %v (the recovery marker could not be written: %v — manually copy %s somewhere safe now)",
|
||||
ErrTargetPossiblyTampered, err, markErr, oldPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrTargetPossiblyTampered, err)
|
||||
}
|
||||
|
||||
// oldBinaryPreservedSuffix names the marker written beside a "<path>.old" that
|
||||
// a failed restore left as the last known-good binary.
|
||||
const oldBinaryPreservedSuffix = ".keep"
|
||||
|
||||
// markOldBinaryPreserved records that oldPath must survive routine cleanup.
|
||||
//
|
||||
// The marker is created with the same exclusive, no-follow semantics as a
|
||||
// staging file, and never truncates. Its pathname is predictable, so under the
|
||||
// writable-install-directory threat model a lower-privileged writer can
|
||||
// pre-create it as a hard link or reparse point; opening that with O_TRUNC
|
||||
// would let the elevated updater write through it into a file of the attacker's
|
||||
// choosing. CREATE_NEW plus FILE_FLAG_OPEN_REPARSE_POINT plus the same
|
||||
// fresh-regular-file check refuses that object instead of writing to it.
|
||||
//
|
||||
// An existing marker is success, not a rewrite: its contents carry no state
|
||||
// beyond "this .old is the last verified binary", so there is nothing to update
|
||||
// and nothing worth opening a pre-existing object for. That also means an entry
|
||||
// planted at the name by someone else reads as marked, which is the fail-safe
|
||||
// direction: it makes the next run PRESERVE the recovery copy.
|
||||
//
|
||||
// A var so a test can force the failure branch. Every way to make the real
|
||||
// CreateFile fail here (an unwritable directory, an over-length name) either
|
||||
// takes the recovery copy down with it or is too platform-fragile to assert on.
|
||||
var markOldBinaryPreserved = func(oldPath string) error {
|
||||
markerPath := oldPath + oldBinaryPreservedSuffix
|
||||
if _, err := os.Lstat(markerPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
pathPtr, err := windows.UTF16PtrFromString(markerPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathPtr,
|
||||
windows.GENERIC_WRITE|windows.DELETE,
|
||||
0,
|
||||
nil,
|
||||
windows.CREATE_NEW,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_FILE_EXISTS) || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
||||
// Something already occupies the marker name. Whatever it is, this
|
||||
// function's only job is to make the next run preserve oldPath, and a
|
||||
// present name does that — without this process writing through an
|
||||
// object it did not create.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("create recovery marker %s: %w", markerPath, err)
|
||||
}
|
||||
marker := os.NewFile(uintptr(handle), markerPath)
|
||||
if err := verifyFreshRegularFile(handle, markerPath); err != nil {
|
||||
_ = deleteFileByHandle(marker)
|
||||
_ = marker.Close()
|
||||
return err
|
||||
}
|
||||
writeErr := writeRecoveryMarker(marker)
|
||||
// A partial marker must not be left beside a recovery copy that the caller
|
||||
// then relocates. Delete the object this process created through its own
|
||||
// handle — never by pathname, which the threat principal can point at
|
||||
// something else once the handle is released.
|
||||
var deleteErr error
|
||||
if writeErr != nil {
|
||||
deleteErr = deleteFileByHandle(marker)
|
||||
}
|
||||
closeErr := marker.Close()
|
||||
if writeErr == nil {
|
||||
// A close failure still leaves the fully written marker in place, and
|
||||
// its presence is the entire state this function records.
|
||||
_ = closeErr
|
||||
return nil
|
||||
}
|
||||
// Below, the write failed. Each remaining branch decides between reporting
|
||||
// that failure and conservatively claiming success — and only a confirmed
|
||||
// absence of the marker earns the report, because claiming success keeps
|
||||
// oldPath as the authoritative recovery location, which is the safe answer.
|
||||
if deleteErr != nil {
|
||||
// The partial marker could not be removed, so it is still there.
|
||||
return nil
|
||||
}
|
||||
if _, statErr := os.Lstat(markerPath); statErr == nil || !errors.Is(statErr, os.ErrNotExist) {
|
||||
// Something occupies the marker name, or its state cannot be established.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("write recovery marker %s: %w", markerPath, writeErr)
|
||||
}
|
||||
|
||||
var writeRecoveryMarker = func(marker *os.File) error {
|
||||
_, err := marker.WriteString("The update at this path failed and could not restore the original binary.\n" +
|
||||
"The file without this marker's .keep suffix is the last binary this updater verified.\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// keepUnmarkedRecoveryCopy moves oldPath out from under routine cleanup when its
|
||||
// marker could not be established, returning the path it now lives at.
|
||||
//
|
||||
// The name is unpredictable for the same reason the staging name is: this runs
|
||||
// in a directory a lower-privileged principal may be able to write, and a fixed
|
||||
// recovery name could be pre-created there to make this rename fail or land
|
||||
// somewhere chosen by someone else. Being unpredictable also means being opaque,
|
||||
// which is why the caller's error names the path.
|
||||
func keepUnmarkedRecoveryCopy(file *os.File, oldPath string) (string, error) {
|
||||
suffix, err := randomStagingSuffix()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: choose recovery path: %v", ErrTargetPossiblyTampered, err)
|
||||
}
|
||||
kept := filepath.Join(filepath.Dir(oldPath), filepath.Base(oldPath)+"."+suffix+".recovery")
|
||||
if _, statErr := os.Lstat(kept); statErr == nil {
|
||||
return "", fmt.Errorf("%w: recovery path %s already exists", ErrTargetPossiblyTampered, kept)
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("%w: inspect recovery path %s: %v", ErrTargetPossiblyTampered, kept, statErr)
|
||||
}
|
||||
if err := renameRecoveryFileByHandle(file, kept); err != nil {
|
||||
return "", fmt.Errorf("%w: move verified recovery copy to %s: %v", ErrTargetPossiblyTampered, kept, err)
|
||||
}
|
||||
if err := verifyPromotedTarget(file, kept); err != nil {
|
||||
// The rename already reported success, so oldPath is no longer a
|
||||
// reliable location for the caller to fall back to — surface kept
|
||||
// anyway so the operator is pointed at the actual (if unverified)
|
||||
// destination instead of a path the move already vacated.
|
||||
return kept, fmt.Errorf("%w: verify recovery copy at %s: %v", ErrTargetPossiblyTampered, kept, err)
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
func openRecoveryCopy(path string) (*os.File, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathPtr,
|
||||
windows.DELETE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyFreshRegularFile(handle, path); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
|
||||
var renameRecoveryFileByHandle = renameOpenFile
|
||||
|
||||
// clearOldBinaryPreserved models the operator accepting the installed binary
|
||||
// by deleting the recovery marker. The recovery copy itself remains preserved.
|
||||
func clearOldBinaryPreserved(oldPath string) {
|
||||
_ = os.Remove(oldPath + oldBinaryPreservedSuffix)
|
||||
}
|
||||
|
||||
// oldBinaryPreserved reports whether a failed restore marked oldPath as the last
|
||||
// known-good binary.
|
||||
//
|
||||
// Only a definite "the marker is not there" answers false. An Lstat that fails
|
||||
// for any other reason (permissions, a transient sharing error) leaves the
|
||||
// question open, and the conservative answer to an open question here is to keep
|
||||
// the copy: deleting it is irreversible, while keeping it costs one stale file.
|
||||
func oldBinaryPreserved(oldPath string) bool {
|
||||
_, err := os.Lstat(oldPath + oldBinaryPreservedSuffix)
|
||||
return err == nil || !errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
type recoveryCleanupRecord struct {
|
||||
Path string `json:"path"`
|
||||
VolumeSerial uint32 `json:"volumeSerial"`
|
||||
FileIndexHigh uint32 `json:"fileIndexHigh"`
|
||||
FileIndexLow uint32 `json:"fileIndexLow"`
|
||||
}
|
||||
|
||||
type recoveryCleanupQueue struct {
|
||||
Records []recoveryCleanupRecord `json:"records"`
|
||||
}
|
||||
|
||||
var recoveryCleanupStateDir = func() (string, error) {
|
||||
root, err := config.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, "zero", "update-recovery"), nil
|
||||
}
|
||||
|
||||
func recoveryCleanupRecordPath(targetPath string) (string, error) {
|
||||
dir, err := recoveryCleanupStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absolute, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(absolute))))
|
||||
return filepath.Join(dir, fmt.Sprintf("%x.json", digest)), nil
|
||||
}
|
||||
|
||||
func validUpdaterRecoveryPath(targetPath string, recoveryPath string) bool {
|
||||
if !strings.EqualFold(filepath.Clean(filepath.Dir(targetPath)), filepath.Clean(filepath.Dir(recoveryPath))) {
|
||||
return false
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(recoveryPath))
|
||||
prefix := strings.ToLower(filepath.Base(targetPath)) + ".zero-update-"
|
||||
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".old") {
|
||||
return false
|
||||
}
|
||||
suffix := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".old")
|
||||
if len(suffix) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, character := range suffix {
|
||||
if !strings.ContainsRune("0123456789abcdef", character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// prepareRecoveryCleanup opens only the exact object vouched for by trusted
|
||||
// per-user state from the previous successful promotion. Recovery discovery is
|
||||
// intentionally broad and filename-based so suspicious state fails closed, but
|
||||
// destructive cleanup never treats an install-directory name as provenance.
|
||||
type recoveryCleanupCandidate struct {
|
||||
file *os.File
|
||||
record recoveryCleanupRecord
|
||||
}
|
||||
|
||||
func loadRecoveryCleanupQueue(targetPath string) recoveryCleanupQueue {
|
||||
recordPath, err := recoveryCleanupRecordPath(targetPath)
|
||||
if err != nil {
|
||||
return recoveryCleanupQueue{}
|
||||
}
|
||||
data, err := os.ReadFile(recordPath)
|
||||
if err != nil {
|
||||
return recoveryCleanupQueue{}
|
||||
}
|
||||
var queue recoveryCleanupQueue
|
||||
if json.Unmarshal(data, &queue) != nil || queue.Records == nil {
|
||||
var legacy recoveryCleanupRecord
|
||||
if json.Unmarshal(data, &legacy) == nil && legacy.Path != "" {
|
||||
queue.Records = []recoveryCleanupRecord{legacy}
|
||||
}
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
func prepareRecoveryCleanup(targetPath string) []recoveryCleanupCandidate {
|
||||
queue := loadRecoveryCleanupQueue(targetPath)
|
||||
var candidates []recoveryCleanupCandidate
|
||||
// Records outlive a single attempt so a temporarily locked copy is still
|
||||
// deleted on a later run, but a record that can never become actionable has
|
||||
// to be retired here or the backlog grows without bound.
|
||||
retained := queue.Records[:0]
|
||||
for _, record := range queue.Records {
|
||||
// Not a name this updater could have produced: unusable as provenance
|
||||
// no matter what appears at it later.
|
||||
if !validUpdaterRecoveryPath(targetPath, record.Path) {
|
||||
continue
|
||||
}
|
||||
// Definitively gone — an operator removed it, or a delete this updater
|
||||
// requested completed once the last handle closed.
|
||||
if _, err := os.Lstat(record.Path); errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
retained = append(retained, record)
|
||||
if oldBinaryPreserved(record.Path) {
|
||||
continue
|
||||
}
|
||||
file, err := openRecoveryCopy(record.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
identity, err := recoveryFileIdentity(file)
|
||||
if err != nil || identity.VolumeSerial != record.VolumeSerial ||
|
||||
identity.FileIndexHigh != record.FileIndexHigh || identity.FileIndexLow != record.FileIndexLow {
|
||||
_ = file.Close()
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, recoveryCleanupCandidate{file: file, record: record})
|
||||
}
|
||||
if len(retained) != len(queue.Records) {
|
||||
queue.Records = retained
|
||||
_ = writeRecoveryCleanupQueue(targetPath, queue)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
type recoveryIdentity struct {
|
||||
VolumeSerial uint32
|
||||
FileIndexHigh uint32
|
||||
FileIndexLow uint32
|
||||
}
|
||||
|
||||
func recoveryFileIdentity(file *os.File) (recoveryIdentity, error) {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &info); err != nil {
|
||||
return recoveryIdentity{}, err
|
||||
}
|
||||
return recoveryIdentity{
|
||||
VolumeSerial: info.VolumeSerialNumber,
|
||||
FileIndexHigh: info.FileIndexHigh,
|
||||
FileIndexLow: info.FileIndexLow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// appendRecoveryCleanupRecord adds the copy this promotion moved aside to the
|
||||
// per-user cleanup backlog. The identity comes from the handle the caller held
|
||||
// across the aside rename, so a pathname substituted afterwards can never be
|
||||
// recorded as updater-owned: the next run reopens recoveryPath and only deletes
|
||||
// it if it is still that same object.
|
||||
func appendRecoveryCleanupRecord(targetPath string, recoveryPath string, identity recoveryIdentity) error {
|
||||
if !validUpdaterRecoveryPath(targetPath, recoveryPath) {
|
||||
return fmt.Errorf("invalid updater recovery path %s", recoveryPath)
|
||||
}
|
||||
record := recoveryCleanupRecord{
|
||||
Path: recoveryPath,
|
||||
VolumeSerial: identity.VolumeSerial,
|
||||
FileIndexHigh: identity.FileIndexHigh,
|
||||
FileIndexLow: identity.FileIndexLow,
|
||||
}
|
||||
queue := loadRecoveryCleanupQueue(targetPath)
|
||||
queue.Records = append(queue.Records, record)
|
||||
return writeRecoveryCleanupQueue(targetPath, queue)
|
||||
}
|
||||
|
||||
func writeRecoveryCleanupQueue(targetPath string, queue recoveryCleanupQueue) error {
|
||||
data, err := json.Marshal(queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordPath, err := recoveryCleanupRecordPath(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(recordPath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(dir, filepath.Base(recordPath)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryPath, recordPath)
|
||||
}
|
||||
|
||||
type fileDispositionInfo struct {
|
||||
DeleteFile byte
|
||||
}
|
||||
|
||||
// deleteFileByHandle marks the object file refers to for deletion, which takes
|
||||
// effect when its last handle closes. It never resolves a pathname, so it can
|
||||
// only ever delete the object this process already holds open.
|
||||
func deleteFileByHandle(file *os.File) error {
|
||||
info := fileDispositionInfo{DeleteFile: 1}
|
||||
return windows.SetFileInformationByHandle(
|
||||
windows.Handle(file.Fd()),
|
||||
windows.FileDispositionInfo,
|
||||
(*byte)(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
)
|
||||
}
|
||||
|
||||
func closeRecoveryCleanupCandidates(candidates []recoveryCleanupCandidate) {
|
||||
for index := range candidates {
|
||||
if candidates[index].file == nil {
|
||||
continue
|
||||
}
|
||||
_ = candidates[index].file.Close()
|
||||
candidates[index].file = nil
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupSupersededRecoveryCopies marks the exact pre-promotion objects for
|
||||
// deletion only after the replacement has been verified. The handles deny
|
||||
// delete sharing, so their entries cannot be substituted in the meantime.
|
||||
//
|
||||
// A record is only retired once its object is actually gone. A copy another
|
||||
// process holds open (an on-access scanner, an operator's editor) stays in the
|
||||
// backlog and is retried by a later promotion, so transient locks cannot leak
|
||||
// full binaries.
|
||||
func cleanupSupersededRecoveryCopies(targetPath string, candidates []recoveryCleanupCandidate) {
|
||||
defer closeRecoveryCleanupCandidates(candidates)
|
||||
queue := loadRecoveryCleanupQueue(targetPath)
|
||||
deleted := make(map[recoveryCleanupRecord]bool)
|
||||
for index := range candidates {
|
||||
candidate := &candidates[index]
|
||||
if err := deleteFileByHandle(candidate.file); err != nil {
|
||||
continue
|
||||
}
|
||||
// FileDispositionInfo removes the entry when the last handle closes, so
|
||||
// releasing ours is what makes the deletion observable.
|
||||
_ = candidate.file.Close()
|
||||
candidate.file = nil
|
||||
if _, err := os.Lstat(candidate.record.Path); errors.Is(err, os.ErrNotExist) {
|
||||
deleted[candidate.record] = true
|
||||
}
|
||||
}
|
||||
remaining := queue.Records[:0]
|
||||
for _, record := range queue.Records {
|
||||
if !deleted[record] {
|
||||
remaining = append(remaining, record)
|
||||
}
|
||||
}
|
||||
queue.Records = remaining
|
||||
_ = writeRecoveryCleanupQueue(targetPath, queue)
|
||||
}
|
||||
@@ -3,63 +3,468 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestReplaceBinaryReplacesRunningBinary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
newPath := filepath.Join(dir, "zero.exe.new")
|
||||
// The replacement path itself (rename the running binary aside, then rename the
|
||||
// staged object into place through its handle) is covered by
|
||||
// TestInstallBinaryInstallsVerifiedBytes and
|
||||
// TestPromoteInstallsTheStagedObjectNotTheStagedPath in
|
||||
// stage_promote_windows_test.go, which exercise it through the staging handle the
|
||||
// production code uses rather than a loose pathname.
|
||||
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newPath, []byte("new-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile new: %v", err)
|
||||
}
|
||||
|
||||
if err := replaceBinary(targetPath, newPath); err != nil {
|
||||
t.Fatalf("replaceBinary: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile target: %v", err)
|
||||
}
|
||||
if string(data) != "new-binary" {
|
||||
t.Fatalf("target content = %q, want %q", data, "new-binary")
|
||||
}
|
||||
if _, err := os.Stat(targetPath + ".old"); err != nil {
|
||||
t.Fatalf("expected the original binary to be preserved at %s.old: %v", targetPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameWithRetrySucceedsImmediately(t *testing.T) {
|
||||
func TestRenameOpenFileWithRetrySucceedsImmediately(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile src: %v", err)
|
||||
}
|
||||
file, err := openRecoveryCopy(src)
|
||||
if err != nil {
|
||||
t.Fatalf("openRecoveryCopy: %v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
if err := renameWithRetry(src, dst); err != nil {
|
||||
t.Fatalf("renameWithRetry: %v", err)
|
||||
if err := renameOpenFileWithRetry(file, dst); err != nil {
|
||||
t.Fatalf("renameOpenFileWithRetry: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dst); err != nil {
|
||||
t.Fatalf("expected dst to exist after rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A permanently-failing rename (source never appears) must exhaust its
|
||||
// retries and surface the underlying error, rather than retrying forever.
|
||||
func TestRenameWithRetryFailsAfterExhaustingAttempts(t *testing.T) {
|
||||
// A permanently-failing rename (the destination is held by a conflicting
|
||||
// exclusive handle for good) must exhaust its retries and surface the
|
||||
// underlying error, rather than retrying forever.
|
||||
func TestRenameOpenFileWithRetryFailsAfterExhaustingAttempts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "does-not-exist")
|
||||
src := filepath.Join(dir, "src")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
for _, path := range []string{src, dst} {
|
||||
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
file, err := openRecoveryCopy(src)
|
||||
if err != nil {
|
||||
t.Fatalf("openRecoveryCopy: %v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
blocker, err := openWithoutSharing(dst)
|
||||
if err != nil {
|
||||
t.Skipf("cannot hold the destination exclusively on this filesystem: %v", err)
|
||||
}
|
||||
defer func() { _ = blocker.Close() }()
|
||||
|
||||
if err := renameWithRetry(missing, dst); err == nil {
|
||||
t.Fatal("expected renameWithRetry to fail for a source that never appears")
|
||||
if err := renameOpenFileWithRetry(file, dst); err == nil {
|
||||
t.Fatal("expected renameOpenFileWithRetry to fail against a permanently blocked destination")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreOriginalBinaryFlagsPossibleTamperingWhenRestoreFails is the
|
||||
// regression test for a review finding on PR #751: when a promotion attempt
|
||||
// fails AND the restore of the preserved original also cannot get past
|
||||
// whatever now occupies targetPath, that combination must be reported as a
|
||||
// security-relevant condition (ErrTargetPossiblyTampered), not folded into
|
||||
// the same generic error a stalled download would produce — the caller needs
|
||||
// to be able to tell "try the update again later" apart from "verify what is
|
||||
// at this path before running it again".
|
||||
func TestRestoreOriginalBinaryFlagsPossibleTamperingWhenRestoreFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "zero.exe.old")
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(oldPath, []byte("original"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile oldPath: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING
|
||||
// cannot get past: an exclusive, no-share open.
|
||||
pathPtr, err := windows.UTF16PtrFromString(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("UTF16PtrFromString: %v", err)
|
||||
}
|
||||
handle, err := windows.CreateFile(pathPtr, windows.GENERIC_WRITE, 0, nil, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFile targetPath: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
restoreErr := restoreOriginalBinary(openRecoveryHandle(t, oldPath), oldPath, targetPath)
|
||||
if restoreErr == nil {
|
||||
t.Fatal("restoreOriginalBinary succeeded despite a conflicting exclusive lock on targetPath, want an error")
|
||||
}
|
||||
if !errors.Is(restoreErr, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("error = %v, want it to wrap ErrTargetPossiblyTampered", restoreErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreOriginalBinaryMarksPreservedCopy pins the other half: the path that
|
||||
// reports "original preserved at <.old>" is the path that makes that true across
|
||||
// runs.
|
||||
func TestRestoreOriginalBinaryMarksPreservedCopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
// Hold the target with no sharing so the restore rename cannot replace it,
|
||||
// which is the condition ErrTargetPossiblyTampered describes.
|
||||
blocker, err := openWithoutSharing(targetPath)
|
||||
if err != nil {
|
||||
t.Skipf("cannot hold the target exclusively on this filesystem: %v", err)
|
||||
}
|
||||
defer func() { _ = blocker.Close() }()
|
||||
|
||||
recovery := openRecoveryHandle(t, oldPath)
|
||||
err = restoreOriginalBinary(recovery, oldPath, targetPath)
|
||||
// Release the handle promote would hold, so these assertions can read the
|
||||
// files it was keeping unsubstitutable.
|
||||
_ = recovery.Close()
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if !oldBinaryPreserved(oldPath) {
|
||||
t.Fatal("a failed restore must mark the preserved copy so later cleanup keeps it")
|
||||
}
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
t.Fatalf("recovery copy was removed after a failed restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkOldBinaryPreservedRefusesPreCreatedLink covers jatmn's #751 finding
|
||||
// that the marker was a predictable link-following truncate write: the path is
|
||||
// fixed, so a lower-privileged writer in the install directory can pre-create it
|
||||
// as a hard link (or reparse point) and have the elevated updater truncate and
|
||||
// write through it into a file of their choosing.
|
||||
func TestMarkOldBinaryPreservedRefusesPreCreatedLink(t *testing.T) {
|
||||
for _, kind := range []string{"hardlink", "symlink"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "zero.exe.old")
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
victim := filepath.Join(t.TempDir(), "victim.txt")
|
||||
const victimContent = "attacker-chosen target that must not be written"
|
||||
if err := os.WriteFile(victim, []byte(victimContent), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile victim: %v", err)
|
||||
}
|
||||
|
||||
markerPath := oldPath + oldBinaryPreservedSuffix
|
||||
var linkErr error
|
||||
switch kind {
|
||||
case "hardlink":
|
||||
linkErr = os.Link(victim, markerPath)
|
||||
case "symlink":
|
||||
linkErr = os.Symlink(victim, markerPath)
|
||||
}
|
||||
if linkErr != nil {
|
||||
t.Skipf("%s unsupported here: %v", kind, linkErr)
|
||||
}
|
||||
|
||||
// Whatever this returns, the one thing it must not do is write through
|
||||
// the planted object. Reporting the marker as present is the safe
|
||||
// answer: it makes the next run PRESERVE the recovery copy.
|
||||
_ = markOldBinaryPreserved(oldPath)
|
||||
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile victim: %v", err)
|
||||
}
|
||||
if string(got) != victimContent {
|
||||
t.Fatalf("marker write followed the planted %s and wrote into %q: %q", kind, victim, got)
|
||||
}
|
||||
// The recovery copy itself is untouched, which is the marker's purpose.
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
t.Fatalf("recovery copy was removed while planting the marker: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkOldBinaryPreservedRemovesPartialMarkerBeforeRelocation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
blocker, err := openWithoutSharing(targetPath)
|
||||
if err != nil {
|
||||
t.Skipf("cannot hold the target exclusively on this filesystem: %v", err)
|
||||
}
|
||||
defer func() { _ = blocker.Close() }()
|
||||
|
||||
originalWrite := writeRecoveryMarker
|
||||
writeRecoveryMarker = func(marker *os.File) error {
|
||||
_, _ = marker.WriteString("partial")
|
||||
return errors.New("injected marker write failure")
|
||||
}
|
||||
t.Cleanup(func() { writeRecoveryMarker = originalWrite })
|
||||
stubRandomStagingSuffix(t, "deadbeef")
|
||||
|
||||
recovery := openRecoveryHandle(t, oldPath)
|
||||
err = restoreOriginalBinary(recovery, oldPath, targetPath)
|
||||
// Release the handle promote would hold, so these assertions can read the
|
||||
// files it was keeping unsubstitutable.
|
||||
_ = recovery.Close()
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
kept := oldPath + ".deadbeef.recovery"
|
||||
if !strings.Contains(err.Error(), kept) {
|
||||
t.Fatalf("error = %v, want relocated recovery path %s", err, kept)
|
||||
}
|
||||
if _, err := os.Lstat(oldPath + oldBinaryPreservedSuffix); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("partial marker survived relocation: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(kept); err != nil || string(got) != "known-good" {
|
||||
t.Fatalf("relocated recovery = %q err=%v, want known-good", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreOriginalBinaryKeepsRecoveryCopyWhenMarkingFails covers the half of
|
||||
// CodeRabbit's marker finding that surfacing the failure alone does not: when no
|
||||
// marker can be established, nothing on disk tells the next run to keep the
|
||||
// copy, so it is moved out from under routine cleanup instead of being left at
|
||||
// the ordinary "<target>.old" recovery name.
|
||||
func TestRestoreOriginalBinaryKeepsRecoveryCopyWhenMarkingFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
// Hold the target with no sharing so the restore rename fails.
|
||||
blocker, err := openWithoutSharing(targetPath)
|
||||
if err != nil {
|
||||
t.Skipf("cannot hold the target exclusively on this filesystem: %v", err)
|
||||
}
|
||||
defer func() { _ = blocker.Close() }()
|
||||
// Force the marker to be unestablishable. Doing it through the seam rather
|
||||
// than by breaking the filesystem keeps oldPath itself intact, which is the
|
||||
// state this behavior is about.
|
||||
originalMark := markOldBinaryPreserved
|
||||
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
|
||||
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
|
||||
stubRandomStagingSuffix(t, "deadbeef")
|
||||
|
||||
recovery := openRecoveryHandle(t, oldPath)
|
||||
err = restoreOriginalBinary(recovery, oldPath, targetPath)
|
||||
// Release the handle promote would hold, so these assertions can read the
|
||||
// files it was keeping unsubstitutable.
|
||||
_ = recovery.Close()
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
kept := oldPath + ".deadbeef.recovery"
|
||||
if !strings.Contains(err.Error(), kept) {
|
||||
t.Fatalf("error = %v, want it to name the path the copy was moved to", err)
|
||||
}
|
||||
got, readErr := os.ReadFile(kept)
|
||||
if readErr != nil {
|
||||
t.Fatalf("recovery copy was not kept: %v", readErr)
|
||||
}
|
||||
if string(got) != "known-good" {
|
||||
t.Fatalf("kept copy = %q, want the last verified binary", got)
|
||||
}
|
||||
if _, err := os.Lstat(oldPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("old recovery path still exists after move: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOldBinaryPreservedTreatsAnUnreadableMarkerAsPresent pins the conservative
|
||||
// side of the marker check: only a definite "not there" allows the copy to be
|
||||
// deleted, because deleting it is irreversible and keeping it costs a file.
|
||||
func TestOldBinaryPreservedTreatsAnUnreadableMarkerAsPresent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "zero.exe.old")
|
||||
if oldBinaryPreserved(oldPath) {
|
||||
t.Fatal("a genuinely absent marker must report not-preserved")
|
||||
}
|
||||
// A directory at the marker name is an entry Lstat can see; anything other
|
||||
// than a clean not-exist keeps the copy.
|
||||
if err := os.Mkdir(oldPath+oldBinaryPreservedSuffix, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir marker: %v", err)
|
||||
}
|
||||
if !oldBinaryPreserved(oldPath) {
|
||||
t.Fatal("an entry at the marker name must count as preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreOriginalBinarySurfacesMarkerWriteFailure covers the #751 P3: the
|
||||
// error promises the original is preserved at <target>.old, but if the marker
|
||||
// cannot be written the next run's cleanup deletes exactly that file. The
|
||||
// operator has to be told to act now rather than at their convenience.
|
||||
func TestRestoreOriginalBinarySurfacesMarkerWriteFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
file := openRecoveryHandle(t, oldPath)
|
||||
// Neither the restore nor the marker nor the relocation can succeed, so the
|
||||
// recovery copy stays at oldPath and the operator must be pointed there.
|
||||
originalMark := markOldBinaryPreserved
|
||||
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
|
||||
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
|
||||
originalRename := renameRecoveryFileByHandle
|
||||
renameRecoveryFileByHandle = func(*os.File, string) error { return errors.New("injected rename failure") }
|
||||
t.Cleanup(func() { renameRecoveryFileByHandle = originalRename })
|
||||
|
||||
err := restoreOriginalBinary(file, oldPath, targetPath)
|
||||
_ = file.Close()
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "recovery marker could not be written") {
|
||||
t.Fatalf("error = %v, want it to disclose the failed marker", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), oldPath) {
|
||||
t.Fatalf("error = %v, want the path the operator must copy", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "later update") {
|
||||
t.Fatalf("error = %v, must not promise cleanup that no longer exists", err)
|
||||
}
|
||||
if got, readErr := os.ReadFile(oldPath); readErr != nil || string(got) != "known-good" {
|
||||
t.Fatalf("recovery copy at %s = %q err=%v, want the last verified binary", oldPath, got, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepUnmarkedRecoveryCopyMovesTheOpenedObject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "zero.exe.old")
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile recovery copy: %v", err)
|
||||
}
|
||||
stubRandomStagingSuffix(t, "deadbeef")
|
||||
|
||||
originalRename := renameRecoveryFileByHandle
|
||||
renameRecoveryFileByHandle = func(file *os.File, kept string) error {
|
||||
// Try to substitute oldPath after keepUnmarkedRecoveryCopy has opened and
|
||||
// verified it. The exclusive delete sharing normally blocks this. Even
|
||||
// on a filesystem that permits it, the handle-bound rename must still
|
||||
// move the verified object rather than the replacement pathname entry.
|
||||
displaced := oldPath + ".attacker-moved"
|
||||
if err := os.Rename(oldPath, displaced); err == nil {
|
||||
if err := os.WriteFile(oldPath, []byte("attacker"), 0o755); err != nil {
|
||||
return fmt.Errorf("plant substituted recovery: %w", err)
|
||||
}
|
||||
}
|
||||
return renameOpenFile(file, kept)
|
||||
}
|
||||
t.Cleanup(func() { renameRecoveryFileByHandle = originalRename })
|
||||
|
||||
recovery := openRecoveryHandle(t, oldPath)
|
||||
kept, err := keepUnmarkedRecoveryCopy(recovery, oldPath)
|
||||
_ = recovery.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("keepUnmarkedRecoveryCopy: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(kept); err != nil || string(got) != "known-good" {
|
||||
t.Fatalf("kept recovery = %q err=%v, want the verified object", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreOriginalBinaryNamesTheRelocationItCouldNotVerify covers the gap
|
||||
// jatmn flagged on the pathname restore and that the handle restore inherited:
|
||||
// when the relocation rename succeeds but the post-move verification does not,
|
||||
// oldPath has already been vacated, so an error naming oldPath sends the
|
||||
// operator to a path that no longer holds anything.
|
||||
func TestRestoreOriginalBinaryNamesTheRelocationItCouldNotVerify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
file := openRecoveryHandle(t, oldPath)
|
||||
stubRandomStagingSuffix(t, "deadbeef")
|
||||
originalMark := markOldBinaryPreserved
|
||||
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
|
||||
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
|
||||
|
||||
kept := oldPath + ".deadbeef.recovery"
|
||||
elsewhere := oldPath + ".deadbeef.elsewhere"
|
||||
originalRename := renameRecoveryFileByHandle
|
||||
renameRecoveryFileByHandle = func(handle *os.File, destination string) error {
|
||||
if destination != kept {
|
||||
return errors.New("injected restore failure")
|
||||
}
|
||||
// The rename reports success but lands somewhere else, so kept is
|
||||
// vacated and verification there fails.
|
||||
return renameOpenFile(handle, elsewhere)
|
||||
}
|
||||
t.Cleanup(func() { renameRecoveryFileByHandle = originalRename })
|
||||
|
||||
err := restoreOriginalBinary(file, oldPath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), kept) {
|
||||
t.Fatalf("error = %v, want it to name the relocation path %s", err, kept)
|
||||
}
|
||||
if _, statErr := os.Lstat(oldPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("oldPath still exists after the relocation rename: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// openRecoveryHandle opens path the way promote holds the binary it moved
|
||||
// aside: with delete access and no delete sharing, so the object cannot be
|
||||
// substituted while the restore path works with it.
|
||||
func openRecoveryHandle(t *testing.T, path string) *os.File {
|
||||
t.Helper()
|
||||
file, err := openRecoveryCopy(path)
|
||||
if err != nil {
|
||||
t.Fatalf("openRecoveryCopy %s: %v", path, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
return file
|
||||
}
|
||||
|
||||
// openWithoutSharing opens an existing file denying every share mode, so a
|
||||
// rename onto it fails the way a principal squatting the executable path does.
|
||||
func openWithoutSharing(path string) (*os.File, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathPtr,
|
||||
windows.GENERIC_READ,
|
||||
0,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const stagingDirPrefix = ".zero-stage-"
|
||||
|
||||
const stagingDirectoryCreateAttempts = 100
|
||||
|
||||
// openStagingDirectory is a test seam for the creation-to-open race.
|
||||
var openStagingDirectory = func(parentFD int, name string) (int, error) {
|
||||
return unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
}
|
||||
|
||||
// createStagedBinary creates a private directory next to targetPath, binds that
|
||||
// directory and its parent to descriptors, and creates the staged file through
|
||||
// the directory descriptor. No later staging operation re-walks the directory
|
||||
// pathname.
|
||||
func createStagedBinary(targetPath string) (*stagedBinary, error) {
|
||||
parentPath := filepath.Dir(targetPath)
|
||||
parentHandle, err := os.Open(parentPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open staging parent: %w", err)
|
||||
}
|
||||
dirName, createdStat, err := createStagingDirectory(parentHandle)
|
||||
if err != nil {
|
||||
_ = parentHandle.Close()
|
||||
return nil, fmt.Errorf("create staging directory: %w", err)
|
||||
}
|
||||
dir := filepath.Join(parentPath, dirName)
|
||||
dirHandle, err := openAndVerifyStagingDirectory(parentHandle, dirName, createdStat)
|
||||
if err != nil {
|
||||
removeStagingDirectoryIfSame(parentHandle, dirName, createdStat)
|
||||
_ = parentHandle.Close()
|
||||
return nil, fmt.Errorf("open staging directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, filepath.Base(targetPath))
|
||||
file, err := createStagingFileAt(dirHandle, filepath.Base(path), path)
|
||||
if err != nil {
|
||||
(&stagedBinary{
|
||||
path: path,
|
||||
dir: dir,
|
||||
dirHandle: dirHandle,
|
||||
parentHandle: parentHandle,
|
||||
}).discardPaths()
|
||||
return nil, err
|
||||
}
|
||||
return &stagedBinary{
|
||||
file: file,
|
||||
path: path,
|
||||
dir: dir,
|
||||
dirHandle: dirHandle,
|
||||
parentHandle: parentHandle,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createStagingDirectory creates and stats the directory relative to the
|
||||
// already-open installation directory. POSIX has no mkdir-and-open primitive,
|
||||
// so openAndVerifyStagingDirectory additionally verifies that the entry still
|
||||
// names this object and is a private directory owned by this effective user.
|
||||
func createStagingDirectory(parent *os.File) (string, unix.Stat_t, error) {
|
||||
for attempt := 0; attempt < stagingDirectoryCreateAttempts; attempt++ {
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return "", unix.Stat_t{}, err
|
||||
}
|
||||
name := stagingDirPrefix + hex.EncodeToString(random[:])
|
||||
if err := unix.Mkdirat(int(parent.Fd()), name, 0o700); err != nil {
|
||||
if errors.Is(err, unix.EEXIST) {
|
||||
continue
|
||||
}
|
||||
return "", unix.Stat_t{}, err
|
||||
}
|
||||
var created unix.Stat_t
|
||||
if err := unix.Fstatat(int(parent.Fd()), name, &created, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
// Nothing else knows this name yet, so a failure here would otherwise
|
||||
// leave an empty .zero-stage-* directory behind for good. Removing it
|
||||
// with rmdir semantics is safe without a prior identity check: the name
|
||||
// was minted from fresh randomness moments ago, and rmdir refuses
|
||||
// anything that is not an empty directory, so the worst an entry
|
||||
// substituted in that window can do is make this call fail.
|
||||
_ = unix.Unlinkat(int(parent.Fd()), name, unix.AT_REMOVEDIR)
|
||||
return "", unix.Stat_t{}, err
|
||||
}
|
||||
return name, created, nil
|
||||
}
|
||||
return "", unix.Stat_t{}, fmt.Errorf("could not allocate a unique staging directory")
|
||||
}
|
||||
|
||||
func openAndVerifyStagingDirectory(parent *os.File, name string, createdStat unix.Stat_t) (*os.File, error) {
|
||||
fd, err := openStagingDirectory(int(parent.Fd()), name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle := os.NewFile(uintptr(fd), name)
|
||||
var handleStat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &handleStat); err != nil {
|
||||
_ = handle.Close()
|
||||
return nil, err
|
||||
}
|
||||
if handleStat.Dev != createdStat.Dev ||
|
||||
handleStat.Ino != createdStat.Ino ||
|
||||
handleStat.Mode&unix.S_IFMT != unix.S_IFDIR ||
|
||||
handleStat.Mode&0o777 != 0o700 ||
|
||||
int(handleStat.Uid) != os.Geteuid() {
|
||||
_ = handle.Close()
|
||||
return nil, fmt.Errorf("staging directory entry was replaced before it could be bound")
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// removeStagingDirectoryIfSame removes only the empty directory entry observed
|
||||
// after creation. It never follows the entry or recursively removes contents;
|
||||
// a substituted or non-empty directory is preserved.
|
||||
func removeStagingDirectoryIfSame(parent *os.File, name string, createdStat unix.Stat_t) {
|
||||
var current unix.Stat_t
|
||||
if err := unix.Fstatat(int(parent.Fd()), name, ¤t, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
return
|
||||
}
|
||||
if current.Dev != createdStat.Dev || current.Ino != createdStat.Ino {
|
||||
return
|
||||
}
|
||||
_ = unix.Unlinkat(int(parent.Fd()), name, unix.AT_REMOVEDIR)
|
||||
}
|
||||
|
||||
// createStagingFileAt creates name inside the directory dir is bound to.
|
||||
// O_EXCL refuses any pre-existing entry — including a dangling symlink, which
|
||||
// POSIX guarantees is not resolved — and O_NOFOLLOW refuses a symlink at the
|
||||
// final component, so neither can redirect the verified bytes elsewhere.
|
||||
func createStagingFileAt(dir *os.File, name string, displayPath string) (*os.File, error) {
|
||||
fd, err := unix.Openat(
|
||||
int(dir.Fd()),
|
||||
name,
|
||||
unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW,
|
||||
0o755,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", displayPath, err)
|
||||
}
|
||||
return os.NewFile(uintptr(fd), displayPath), nil
|
||||
}
|
||||
|
||||
func (staged *stagedBinary) promote(targetPath string) error {
|
||||
if err := staged.file.Chmod(0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := staged.verifyStagedIdentity(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unix.Renameat(
|
||||
int(staged.dirHandle.Fd()),
|
||||
filepath.Base(staged.path),
|
||||
int(staged.parentHandle.Fd()),
|
||||
filepath.Base(targetPath),
|
||||
); err != nil {
|
||||
return fmt.Errorf("rename staged binary onto %s: %w", targetPath, err)
|
||||
}
|
||||
staged.path = targetPath
|
||||
staged.promoted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (staged *stagedBinary) verifyStagedIdentity() error {
|
||||
var handleStat unix.Stat_t
|
||||
if err := unix.Fstat(int(staged.file.Fd()), &handleStat); err != nil {
|
||||
return fmt.Errorf("stat staged binary: %w", err)
|
||||
}
|
||||
var childStat unix.Stat_t
|
||||
if err := unix.Fstatat(
|
||||
int(staged.dirHandle.Fd()),
|
||||
filepath.Base(staged.path),
|
||||
&childStat,
|
||||
unix.AT_SYMLINK_NOFOLLOW,
|
||||
); err != nil {
|
||||
return fmt.Errorf("stat staged binary path %s: %w", staged.path, err)
|
||||
}
|
||||
if childStat.Ino != handleStat.Ino || childStat.Dev != handleStat.Dev {
|
||||
return fmt.Errorf("staged binary %s was replaced after it was written", staged.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardOpenObject has nothing to do here: discardPaths already removes the
|
||||
// staged child through the descriptor bound to the private staging directory,
|
||||
// which no other principal can write, so there is no pathname handoff to close.
|
||||
func (staged *stagedBinary) discardOpenObject() {}
|
||||
|
||||
// discardPaths removes the child through the bound directory descriptor and
|
||||
// removes the directory only while its original parent entry still names it.
|
||||
func (staged *stagedBinary) discardPaths() {
|
||||
if staged.dirHandle != nil && !staged.promoted {
|
||||
_ = unix.Unlinkat(int(staged.dirHandle.Fd()), filepath.Base(staged.path), 0)
|
||||
}
|
||||
if staged.dirHandle != nil {
|
||||
var bound unix.Stat_t
|
||||
var current unix.Stat_t
|
||||
boundErr := unix.Fstat(int(staged.dirHandle.Fd()), &bound)
|
||||
currentErr := unix.Fstatat(
|
||||
int(staged.parentHandle.Fd()),
|
||||
filepath.Base(staged.dir),
|
||||
¤t,
|
||||
unix.AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
_ = staged.dirHandle.Close()
|
||||
if boundErr == nil &&
|
||||
currentErr == nil &&
|
||||
bound.Dev == current.Dev &&
|
||||
bound.Ino == current.Ino {
|
||||
_ = unix.Unlinkat(int(staged.parentHandle.Fd()), filepath.Base(staged.dir), unix.AT_REMOVEDIR)
|
||||
}
|
||||
}
|
||||
if staged.parentHandle != nil {
|
||||
_ = staged.parentHandle.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stageAtPath drives the production staging primitive — createStagingFileAt,
|
||||
// the one createStagedBinary calls — against path by binding a descriptor to
|
||||
// its parent first. The tests below go through it rather than a path-taking
|
||||
// helper of their own so that weakening the real open flags fails them.
|
||||
func stageAtPath(path string) (*os.File, error) {
|
||||
parent, err := os.Open(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = parent.Close() }()
|
||||
return createStagingFileAt(parent, filepath.Base(path), path)
|
||||
}
|
||||
|
||||
// TestCreateStagingFileRefusesPrecreatedHardLink is the regression test for
|
||||
// #742: a lower-privileged attacker who can write in the installation
|
||||
// directory pre-creates the staging path as a hard link to another file the
|
||||
// (possibly elevated) updater can write. Staging must fail instead of opening
|
||||
// and truncating through that link.
|
||||
func TestCreateStagingFileRefusesPrecreatedHardLink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
victim := filepath.Join(dir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile victim: %v", err)
|
||||
}
|
||||
staged := filepath.Join(dir, "staged")
|
||||
if err := os.Link(victim, staged); err != nil {
|
||||
t.Fatalf("Link: %v", err)
|
||||
}
|
||||
|
||||
if _, err := stageAtPath(staged); err == nil {
|
||||
t.Fatal("staging succeeded through a pre-existing hard link, want error")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(victim)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile victim: %v", err)
|
||||
}
|
||||
if string(data) != "do not touch" {
|
||||
t.Fatalf("victim content = %q, want unchanged", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStagingFileRefusesPrecreatedSymlink covers the reparse-point
|
||||
// variant of the same #742 primitive: the staging path pre-created as a
|
||||
// symlink to another writable file.
|
||||
func TestCreateStagingFileRefusesPrecreatedSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
victim := filepath.Join(dir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile victim: %v", err)
|
||||
}
|
||||
staged := filepath.Join(dir, "staged")
|
||||
if err := os.Symlink(victim, staged); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
if _, err := stageAtPath(staged); err == nil {
|
||||
t.Fatal("staging succeeded through a pre-existing symlink, want error")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(victim)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile victim: %v", err)
|
||||
}
|
||||
if string(data) != "do not touch" {
|
||||
t.Fatalf("victim content = %q, want unchanged", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStagingFileSucceedsForFreshPath is the control: a path with
|
||||
// nothing pre-existing must still work normally.
|
||||
func TestCreateStagingFileSucceedsForFreshPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "staged")
|
||||
|
||||
file, err := stageAtPath(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stageAtPath: %v", err)
|
||||
}
|
||||
if _, err := file.WriteString("payload"); err != nil {
|
||||
t.Fatalf("WriteString: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "payload" {
|
||||
t.Fatalf("content = %q, want %q", data, "payload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStagingFileConcurrentRaceOnlyOneWinner exercises a race on a
|
||||
// single fixed path (installBinary normally avoids this by randomizing the
|
||||
// name, but the exclusive-creation guarantee must hold regardless): exactly
|
||||
// one concurrent caller may create the file, and the rest must fail cleanly
|
||||
// rather than silently truncate the winner's content.
|
||||
func TestCreateStagingFileConcurrentRaceOnlyOneWinner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "staged")
|
||||
|
||||
const attempts = 16
|
||||
var wg sync.WaitGroup
|
||||
successes := make([]bool, attempts)
|
||||
for i := range attempts {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
file, err := stageAtPath(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
successes[i] = true
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
winners := 0
|
||||
for _, ok := range successes {
|
||||
if ok {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("concurrent staging winners = %d, want exactly 1", winners)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPromoteRefusesASubstitutedStagingEntry is the regression test for the live
|
||||
// handoff half of #742: randomizing the staging name and creating it exclusively
|
||||
// stops PRE-creation, but not substitution after the verified bytes are written.
|
||||
// POSIX cannot rename by descriptor, so staging happens inside a private 0700
|
||||
// directory the attacker cannot write to, and promote additionally verifies the
|
||||
// entry still names the object it wrote before renaming it into place.
|
||||
func TestPromoteRefusesASubstitutedStagingEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
staged, err := stageBinary(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stageBinary: %v", err)
|
||||
}
|
||||
defer staged.discard()
|
||||
|
||||
// First line of defence: the staging directory is private, so a principal who
|
||||
// can write in the installation directory cannot reach the entry at all.
|
||||
info, err := os.Stat(staged.dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat staging directory: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o700 {
|
||||
t.Fatalf("staging directory mode = %#o, want 0700", perm)
|
||||
}
|
||||
|
||||
// Second line of defence: rehearse the substitution anyway (the test runs as
|
||||
// the directory's owner, so it can do what an attacker cannot) and require
|
||||
// promote to refuse instead of installing the substitute.
|
||||
substitute := filepath.Join(staged.dir, "substitute")
|
||||
if err := os.WriteFile(substitute, []byte("attacker-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile substitute: %v", err)
|
||||
}
|
||||
if err := os.Rename(substitute, staged.path); err != nil {
|
||||
t.Fatalf("Rename substitute over the staging entry: %v", err)
|
||||
}
|
||||
|
||||
if err := staged.promote(targetPath); err == nil {
|
||||
t.Fatal("promote installed a substituted staging entry, want a refusal")
|
||||
}
|
||||
installed, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile target: %v", err)
|
||||
}
|
||||
if string(installed) != "old-binary" {
|
||||
t.Fatalf("target = %q, want the original binary left in place", installed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteSurvivesAncestorDirectoryReplacement is the regression test for a
|
||||
// review finding on PR #751: the private staging directory's 0700 mode
|
||||
// protects its CONTENTS from a principal who can write in the installation
|
||||
// directory, but not its own directory ENTRY — that principal can still
|
||||
// rename the staging directory itself out of the way and recreate a
|
||||
// look-alike at the same path, with an attacker file at the same basename,
|
||||
// in the gap between verifyStagedIdentity returning and the final rename
|
||||
// running. A plain pathname rename would re-resolve through the impostor at
|
||||
// that point; promote must instead stay bound to the exact directory whose
|
||||
// identity was already checked, via the directory descriptor opened when the
|
||||
// staging directory was created.
|
||||
func TestPromoteSurvivesAncestorDirectoryReplacement(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
staged, err := stageBinary(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stageBinary: %v", err)
|
||||
}
|
||||
discarded := false
|
||||
defer func() {
|
||||
if !discarded {
|
||||
staged.discard()
|
||||
}
|
||||
}()
|
||||
|
||||
// Rehearse the ancestor swap: move the real staging directory aside (the
|
||||
// test runs as its owner, so it can do what a merely writable-parent
|
||||
// attacker — who only needs rename rights on dir, not on the staging
|
||||
// directory's own contents — can also do), then recreate a look-alike at
|
||||
// the original path with an attacker file at the same basename.
|
||||
base := filepath.Base(staged.path)
|
||||
movedDir := staged.dir + "-moved"
|
||||
if err := os.Rename(staged.dir, movedDir); err != nil {
|
||||
t.Fatalf("Rename staging directory aside: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(staged.dir, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir impostor staging directory: %v", err)
|
||||
}
|
||||
impostorFile := filepath.Join(staged.dir, base)
|
||||
if err := os.WriteFile(impostorFile, []byte("attacker-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile impostor file: %v", err)
|
||||
}
|
||||
|
||||
if err := staged.promote(targetPath); err != nil {
|
||||
t.Fatalf("promote: %v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile target: %v", err)
|
||||
}
|
||||
if string(installed) != "verified-binary" {
|
||||
t.Fatalf("target = %q, want the verified bytes from the original staging directory", installed)
|
||||
}
|
||||
staged.discard()
|
||||
discarded = true
|
||||
// The impostor must be left untouched: promote should never have looked at
|
||||
// it, let alone consumed or removed it during deferred cleanup.
|
||||
if impostor, err := os.ReadFile(impostorFile); err != nil {
|
||||
t.Fatalf("ReadFile impostor file: %v", err)
|
||||
} else if string(impostor) != "attacker-binary" {
|
||||
t.Fatalf("impostor file = %q, want it left untouched", impostor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStagedBinaryRejectsDirectoryReplacementBeforeOpen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero")
|
||||
var impostorMarker string
|
||||
original := openStagingDirectory
|
||||
openStagingDirectory = func(parentFD int, name string) (int, error) {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.Rename(path, path+"-original"); err != nil {
|
||||
t.Fatalf("Rename original staging directory: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir impostor: %v", err)
|
||||
}
|
||||
impostorMarker = filepath.Join(path, "keep")
|
||||
if err := os.WriteFile(impostorMarker, []byte("attacker"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile impostor marker: %v", err)
|
||||
}
|
||||
return original(parentFD, name)
|
||||
}
|
||||
defer func() { openStagingDirectory = original }()
|
||||
|
||||
if staged, err := createStagedBinary(targetPath); err == nil {
|
||||
staged.discard()
|
||||
t.Fatal("createStagedBinary accepted a directory replaced before open")
|
||||
}
|
||||
if marker, err := os.ReadFile(impostorMarker); err != nil {
|
||||
t.Fatalf("substituted directory was removed during failed creation: %v", err)
|
||||
} else if string(marker) != "attacker" {
|
||||
t.Fatalf("substituted directory marker = %q, want it preserved", marker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStagedBinaryCleansUpAfterDirectoryOpenFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero")
|
||||
original := openStagingDirectory
|
||||
openStagingDirectory = func(parentFD int, name string) (int, error) {
|
||||
return -1, errors.New("forced open failure")
|
||||
}
|
||||
defer func() { openStagingDirectory = original }()
|
||||
|
||||
if staged, err := createStagedBinary(targetPath); err == nil {
|
||||
staged.discard()
|
||||
t.Fatal("createStagedBinary succeeded despite a forced directory-open failure")
|
||||
}
|
||||
assertNoStagingLeftovers(t, dir)
|
||||
}
|
||||
|
||||
// TestInstallBinaryInstallsVerifiedBytes is the success control: the ordinary
|
||||
// path must still install the staged bytes, executable, with nothing left over.
|
||||
func TestInstallBinaryInstallsVerifiedBytes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
if err := installBinary(sourcePath, targetPath); err != nil {
|
||||
t.Fatalf("installBinary: %v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile installed: %v", err)
|
||||
}
|
||||
if string(installed) != "verified-binary" {
|
||||
t.Fatalf("installed binary = %q, want the verified bytes", installed)
|
||||
}
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat installed: %v", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("installed binary mode = %#o, want the executable bit set", info.Mode().Perm())
|
||||
}
|
||||
assertNoStagingLeftovers(t, dir)
|
||||
}
|
||||
|
||||
// TestInstallBinaryCleansUpWhenStagingFails covers the cleanup ordering: a
|
||||
// failure after the staging object exists must not leave it behind, because each
|
||||
// attempt now uses a fresh random name that the next attempt never reuses.
|
||||
func TestInstallBinaryCleansUpWhenStagingFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
|
||||
if err := installBinary(filepath.Join(t.TempDir(), "missing-source"), targetPath); err == nil {
|
||||
t.Fatal("installBinary with an unreadable source succeeded, want error")
|
||||
}
|
||||
assertNoStagingLeftovers(t, dir)
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
stateDir, err := os.MkdirTemp("", "zero-update-recovery-test-")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "create updater test state directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
recoveryCleanupStateDir = func() (string, error) { return stateDir, nil }
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(stateDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// TestPromoteInstallsTheStagedObjectNotTheStagedPath is the regression test for
|
||||
// the live handoff half of #742: randomizing the staging name and creating it
|
||||
// exclusively stops PRE-creation, but not substitution after the verified bytes
|
||||
// are written. Windows renames through the staging HANDLE, so a substituted entry
|
||||
// at the staging pathname is simply not what gets promoted.
|
||||
func TestPromoteInstallsTheStagedObjectNotTheStagedPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
staged, err := stageBinary(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stageBinary: %v", err)
|
||||
}
|
||||
discarded := false
|
||||
defer func() {
|
||||
if !discarded {
|
||||
staged.discard()
|
||||
}
|
||||
}()
|
||||
|
||||
// Rehearse the strongest form of the substitution: the staging entry is
|
||||
// replaced wholesale between the write and the swap. (A real attacker also has
|
||||
// to get past the exclusive share mode this handle holds; the test does not,
|
||||
// which only makes the check stricter.)
|
||||
substituted := false
|
||||
if err := os.Remove(staged.path); err == nil {
|
||||
if err := os.WriteFile(staged.path, []byte("attacker-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile substituted staging entry: %v", err)
|
||||
}
|
||||
substituted = true
|
||||
}
|
||||
|
||||
promoteErr := staged.promote(targetPath)
|
||||
// The staging handle keeps the promoted (or, on failure, discarded) file open
|
||||
// with an exclusive share mode, so release it before reading installed bytes
|
||||
// (installBinary's deferred discard does the same).
|
||||
staged.discard()
|
||||
discarded = true
|
||||
|
||||
if promoteErr != nil {
|
||||
// Fully unlinking the staging file (its directory entry removed, then a
|
||||
// new file recreated at that name) is not something every Windows build
|
||||
// honors a same-handle rename back into: verifyPromotedTarget's post-
|
||||
// rename identity check can find nothing at targetPath and fail the
|
||||
// promotion, which restoreOriginalBinary then recovers from by moving
|
||||
// the pre-update binary back. That is this test's real security
|
||||
// property holding — the attacker's substituted bytes are never
|
||||
// installed — just via the fail-closed path instead of the handle-
|
||||
// rename defeating the substitution outright. Only accept the failure
|
||||
// when it actually happened via a real substitution; any other promote
|
||||
// error is a genuine regression.
|
||||
if !substituted {
|
||||
t.Fatalf("promote failed without substitution: %v", promoteErr)
|
||||
}
|
||||
if installed, readErr := os.ReadFile(targetPath); readErr == nil && string(installed) == "attacker-binary" {
|
||||
t.Fatalf("attacker-controlled bytes were installed: %q", installed)
|
||||
}
|
||||
t.Logf("promote refused after full-unlink substitution (%v); attacker bytes were not installed", promoteErr)
|
||||
return
|
||||
}
|
||||
|
||||
installed, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile installed: %v", err)
|
||||
}
|
||||
if string(installed) != "verified-binary" {
|
||||
t.Fatalf("installed binary = %q, want the verified bytes", installed)
|
||||
}
|
||||
if !substituted {
|
||||
t.Log("the staging entry could not be replaced (exclusive share mode); the handle-bound rename was still exercised")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteRejectsALyingRenameByHandle is the regression test for a review
|
||||
// finding on PR #751: SetFileInformationByHandle reporting success is not, on
|
||||
// its own, proof the object actually ended up at targetPath. Some Windows
|
||||
// versions have been observed accepting the rename call against a handle
|
||||
// whose directory entry was substituted out from under it without the object
|
||||
// actually moving — this simulates that by stubbing the rename to lie, since
|
||||
// the real trigger condition is Windows-version-specific and not reliably
|
||||
// reproducible on demand. Without verifyPromotedTarget, promote would return
|
||||
// nil while targetPath silently ends up missing, reporting a successful
|
||||
// update that actually stranded the user without an executable at all.
|
||||
func TestPromoteRejectsALyingRenameByHandle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
staged, err := stageBinary(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stageBinary: %v", err)
|
||||
}
|
||||
defer staged.discard()
|
||||
|
||||
original := renameFileByHandle
|
||||
renameFileByHandle = func(file *os.File, target string) error {
|
||||
return nil // lie: report success without touching anything
|
||||
}
|
||||
defer func() { renameFileByHandle = original }()
|
||||
|
||||
promoteErr := staged.promote(targetPath)
|
||||
if promoteErr == nil {
|
||||
t.Fatal("promote reported success for a rename that never actually happened, want an error")
|
||||
}
|
||||
if !strings.Contains(promoteErr.Error(), "unreachable") {
|
||||
t.Fatalf("error = %q, want it to explain the target is unreachable", promoteErr.Error())
|
||||
}
|
||||
installed, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile target: %v", err)
|
||||
}
|
||||
if string(installed) != "old-binary" {
|
||||
t.Fatalf("target = %q, want the original binary restored", installed)
|
||||
}
|
||||
if _, err := os.Stat(targetPath + ".old"); !os.IsNotExist(err) {
|
||||
t.Fatalf(".old leftover survived a successful restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A promotion failure followed by a blocked restore is security-relevant all
|
||||
// the way through installBinary; its contextual wrappers must not erase the
|
||||
// sentinel that callers of Apply use to distinguish possible path tampering.
|
||||
func TestInstallBinaryPreservesPossibleTamperingError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
original := renameFileByHandle
|
||||
var conflicting windows.Handle
|
||||
renameFileByHandle = func(_ *os.File, target string) error {
|
||||
pathPtr, err := windows.UTF16PtrFromString(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conflicting, err = windows.CreateFile(pathPtr, windows.GENERIC_WRITE, 0, nil, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create conflicting target: %w", err)
|
||||
}
|
||||
return errors.New("injected promotion failure")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
renameFileByHandle = original
|
||||
if conflicting != 0 {
|
||||
_ = windows.CloseHandle(conflicting)
|
||||
}
|
||||
})
|
||||
originalMark := markOldBinaryPreserved
|
||||
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
|
||||
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
|
||||
const suffix = "deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
stubRandomStagingSuffix(t, suffix)
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want it to wrap ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "original preserved at "+targetPath+".old") {
|
||||
t.Fatalf("installBinary error falsely claims the relocated copy remains at .old: %v", err)
|
||||
}
|
||||
expectedRecovery := targetPath + ".zero-update-" + suffix + ".old." + suffix + ".recovery"
|
||||
if !strings.Contains(err.Error(), expectedRecovery) {
|
||||
t.Fatalf("installBinary error = %v, want the authoritative relocated recovery path", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteRefusesWhileRecoveryCopyIsMarked covers jatmn's #751 P1: skipping
|
||||
// the pre-rename cleanup was not enough to protect a marked recovery copy.
|
||||
// os.Rename uses MOVEFILE_REPLACE_EXISTING on Windows, so renaming the running
|
||||
// binary aside overwrote the last verified copy with the unverified bytes the
|
||||
// earlier failure left at the target — and if this promotion then failed,
|
||||
// restoreOriginalBinary could only move those unverified bytes back.
|
||||
func TestPromoteRefusesWhileRecoveryCopyIsMarked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile old binary: %v", err)
|
||||
}
|
||||
if err := markOldBinaryPreserved(oldPath); err != nil {
|
||||
t.Fatalf("markOldBinaryPreserved: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want a refusal wrapping ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
// The whole point: the known-good copy is still there afterwards.
|
||||
got, readErr := os.ReadFile(oldPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("recovery copy was destroyed by the retry: %v", readErr)
|
||||
}
|
||||
if string(got) != "known-good" {
|
||||
t.Fatalf("recovery copy = %q, want the last verified binary", got)
|
||||
}
|
||||
// And the error names both moves that end the state.
|
||||
for _, want := range []string{oldPath, targetPath, oldPath + oldBinaryPreservedSuffix} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want it to name %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the marker is the operator accepting the installed binary; the
|
||||
// next promotion proceeds normally without destroying the recovery copy.
|
||||
clearOldBinaryPreserved(oldPath)
|
||||
stubRandomStagingSuffix(t, "deadbeef")
|
||||
if err := installBinary(sourcePath, targetPath); err != nil {
|
||||
t.Fatalf("installBinary after the operator cleared the marker: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(targetPath); err != nil || string(data) != "verified-binary" {
|
||||
t.Fatalf("target = %q err=%v, want the verified bytes installed", data, err)
|
||||
}
|
||||
if data, err := os.ReadFile(oldPath); err != nil || string(data) != "known-good" {
|
||||
t.Fatalf("recovery copy = %q err=%v, want known-good after retry", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRefusesRetryAfterInterruptedAside(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
oldPath := targetPath + ".old"
|
||||
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile recovery copy: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if _, err := os.Lstat(targetPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing target was unexpectedly created: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(oldPath); err != nil || string(data) != "known-good" {
|
||||
t.Fatalf("recovery copy = %q err=%v, want known-good", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRefusesMarkedRandomAsideRecovery(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
canonicalOld := targetPath + ".old"
|
||||
randomOld := targetPath + ".deadbeef.old"
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(canonicalOld, []byte("stale-older-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile canonical recovery: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(randomOld, []byte("last-known-good"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile random recovery: %v", err)
|
||||
}
|
||||
if err := markOldBinaryPreserved(randomOld); err != nil {
|
||||
t.Fatalf("mark random recovery: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
for _, want := range []string{randomOld, randomOld + oldBinaryPreservedSuffix} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want marked random recovery path %s", err, want)
|
||||
}
|
||||
}
|
||||
if got, err := os.ReadFile(randomOld); err != nil || string(got) != "last-known-good" {
|
||||
t.Fatalf("random recovery = %q err=%v, want last-known-good", got, err)
|
||||
}
|
||||
if got, err := os.ReadFile(targetPath); err != nil || string(got) != "unverified" {
|
||||
t.Fatalf("target = %q err=%v, want refusal before promotion", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRefusesAmbiguousRecoveryWhenTargetIsMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
canonicalOld := targetPath + ".old"
|
||||
randomOld := targetPath + ".deadbeef.old"
|
||||
if err := os.WriteFile(canonicalOld, []byte("older-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile canonical recovery: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(randomOld, []byte("last-running-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile random recovery: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
for _, want := range []string{"ambiguous", canonicalOld, randomOld} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(targetPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing target was unexpectedly created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPromotedTargetRejectsDifferentRegularFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stagedPath := filepath.Join(dir, "staged.exe")
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(stagedPath, []byte("verified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile staged: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte("attacker"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
staged, err := os.Open(stagedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Open staged: %v", err)
|
||||
}
|
||||
defer func() { _ = staged.Close() }()
|
||||
|
||||
if err := verifyPromotedTarget(staged, targetPath); err == nil {
|
||||
t.Fatal("verifyPromotedTarget accepted a different regular file at targetPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPromotedTargetRejectsAHardLinkedName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stagedPath := filepath.Join(dir, "staged.exe")
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(stagedPath, []byte("verified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile staged: %v", err)
|
||||
}
|
||||
if err := os.Link(stagedPath, targetPath); err != nil {
|
||||
t.Fatalf("Link target: %v", err)
|
||||
}
|
||||
staged, err := os.Open(stagedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Open staged: %v", err)
|
||||
}
|
||||
defer func() { _ = staged.Close() }()
|
||||
|
||||
if err := verifyPromotedTarget(staged, targetPath); err == nil {
|
||||
t.Fatal("verifyPromotedTarget accepted a second hard-linked name without a completed rename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallBinaryThroughReparsePointAncestor(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
realDir := filepath.Join(root, "real")
|
||||
if err := os.Mkdir(realDir, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir real install directory: %v", err)
|
||||
}
|
||||
linkedDir := filepath.Join(root, "linked")
|
||||
if err := os.Symlink(realDir, linkedDir); err != nil {
|
||||
t.Skipf("directory symlink unavailable: %v", err)
|
||||
}
|
||||
targetPath := filepath.Join(linkedDir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
if err := installBinary(sourcePath, targetPath); err != nil {
|
||||
t.Fatalf("installBinary through reparse-point ancestor: %v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(filepath.Join(realDir, "zero.exe"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile installed: %v", err)
|
||||
}
|
||||
if string(installed) != "verified-binary" {
|
||||
t.Fatalf("installed binary = %q, want the verified bytes", installed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallBinaryInstallsVerifiedBytes is the success control for the ordinary
|
||||
// path: the staged bytes land at the target, the running binary is preserved at
|
||||
// an updater-owned recovery path, and no staging artifact survives.
|
||||
func TestInstallBinaryInstallsVerifiedBytes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
if err := installBinary(sourcePath, targetPath); err != nil {
|
||||
t.Fatalf("installBinary: %v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile installed: %v", err)
|
||||
}
|
||||
if string(installed) != "verified-binary" {
|
||||
t.Fatalf("installed binary = %q, want the verified bytes", installed)
|
||||
}
|
||||
recoveries, err := existingRecoveryPaths(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("existingRecoveryPaths: %v", err)
|
||||
}
|
||||
if len(recoveries) != 1 || !strings.Contains(filepath.Base(recoveries[0]), ".zero-update-") {
|
||||
t.Fatalf("recovery paths = %v, want one namespaced updater recovery", recoveries)
|
||||
}
|
||||
if old, err := os.ReadFile(recoveries[0]); err != nil {
|
||||
t.Fatalf("the replaced binary must be preserved as the recovery copy: %v", err)
|
||||
} else if string(old) != "old-binary" {
|
||||
t.Fatalf("preserved binary = %q, want the previous one", old)
|
||||
}
|
||||
assertNoStagingLeftovers(t, dir)
|
||||
}
|
||||
|
||||
func TestInstallBinaryPreservesArbitraryOldFilesDuringCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
manualBackup := targetPath + ".before-manual-patch.old"
|
||||
if err := os.WriteFile(manualBackup, []byte("manual-backup"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile manual backup: %v", err)
|
||||
}
|
||||
// Also cover a name that resembles the updater namespace but does not carry
|
||||
// the exact 128-bit hexadecimal suffix generated by randomStagingSuffix.
|
||||
lookalike := targetPath + ".zero-update-not-owned.old"
|
||||
if err := os.WriteFile(lookalike, []byte("lookalike-backup"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile lookalike backup: %v", err)
|
||||
}
|
||||
plantedNamespaced := targetPath + ".zero-update-00000000000000000000000000000000.old"
|
||||
if err := os.WriteFile(plantedNamespaced, []byte("planted-namespaced-backup"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile planted namespaced backup: %v", err)
|
||||
}
|
||||
|
||||
for version := 1; version <= 3; version++ {
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte(fmt.Sprintf("version-%d", version)), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
if err := installBinary(sourcePath, targetPath); err != nil {
|
||||
t.Fatalf("installBinary version %d: %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
for path, want := range map[string]string{
|
||||
manualBackup: "manual-backup",
|
||||
lookalike: "lookalike-backup",
|
||||
plantedNamespaced: "planted-namespaced-backup",
|
||||
} {
|
||||
if got, err := os.ReadFile(path); err != nil || string(got) != want {
|
||||
t.Fatalf("backup %s = %q err=%v, want %q", path, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryCleanupRefusesSubstitutedAside pins the provenance rule that
|
||||
// makes handle-bound cleanup safe: a recorded recovery path whose object has
|
||||
// been swapped out underneath the record is never opened as a cleanup
|
||||
// candidate, so the substitute is not deleted on the next promotion.
|
||||
func TestRecoveryCleanupRefusesSubstitutedAside(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
recoveryPath := targetPath + ".zero-update-0123456789abcdef0123456789abcdef.old"
|
||||
if err := os.WriteFile(recoveryPath, []byte("moved-aside-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile recovery: %v", err)
|
||||
}
|
||||
original, err := openRecoveryCopy(recoveryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("openRecoveryCopy: %v", err)
|
||||
}
|
||||
identity, err := recoveryFileIdentity(original)
|
||||
_ = original.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("recoveryFileIdentity: %v", err)
|
||||
}
|
||||
if err := appendRecoveryCleanupRecord(targetPath, recoveryPath, identity); err != nil {
|
||||
t.Fatalf("appendRecoveryCleanupRecord: %v", err)
|
||||
}
|
||||
if err := os.Rename(recoveryPath, recoveryPath+".displaced"); err != nil {
|
||||
t.Fatalf("Rename recovery: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(recoveryPath, []byte("substituted-file"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile substitute: %v", err)
|
||||
}
|
||||
|
||||
candidates := prepareRecoveryCleanup(targetPath)
|
||||
if len(candidates) != 0 {
|
||||
t.Fatalf("cleanup candidates = %d, want the substituted aside to be refused", len(candidates))
|
||||
}
|
||||
cleanupSupersededRecoveryCopies(targetPath, candidates)
|
||||
if got, err := os.ReadFile(recoveryPath); err != nil || string(got) != "substituted-file" {
|
||||
t.Fatalf("substituted file = %q err=%v, want it left untouched", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendRecoveryCleanupRecordRejectsForeignPath keeps the trusted record
|
||||
// from ever vouching for a name this updater could not have created, which is
|
||||
// what stops cleanup from deleting an operator's own backup.
|
||||
func TestAppendRecoveryCleanupRecordRejectsForeignPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
foreign := targetPath + ".before-manual-patch.old"
|
||||
if err := os.WriteFile(foreign, []byte("operator-backup"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile backup: %v", err)
|
||||
}
|
||||
if err := appendRecoveryCleanupRecord(targetPath, foreign, recoveryIdentity{}); err == nil {
|
||||
t.Fatal("appendRecoveryCleanupRecord accepted a path this updater never created")
|
||||
}
|
||||
recordPath, err := recoveryCleanupRecordPath(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("recoveryCleanupRecordPath: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(recordPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("trusted cleanup record was written for a foreign backup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryCleanupRetiresRecordsForVanishedCopies keeps the backlog that
|
||||
// makes transient-lock retries possible from becoming its own unbounded growth:
|
||||
// a record whose object an operator already removed can never become
|
||||
// actionable, so it must not be carried forever.
|
||||
func TestRecoveryCleanupRetiresRecordsForVanishedCopies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(source, []byte("version-1"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
if err := installBinary(source, targetPath); err != nil {
|
||||
t.Fatalf("installBinary: %v", err)
|
||||
}
|
||||
recoveries, err := existingRecoveryPaths(targetPath)
|
||||
if err != nil || len(recoveries) != 1 {
|
||||
t.Fatalf("recovery paths = %v err=%v, want exactly one", recoveries, err)
|
||||
}
|
||||
if got := recordedRecoveryCleanupCount(t, targetPath); got != 1 {
|
||||
t.Fatalf("recorded cleanup entries = %d, want 1", got)
|
||||
}
|
||||
// The operator removes the recovery copy themselves.
|
||||
if err := os.Remove(recoveries[0]); err != nil {
|
||||
t.Fatalf("Remove recovery copy: %v", err)
|
||||
}
|
||||
|
||||
closeRecoveryCleanupCandidates(prepareRecoveryCleanup(targetPath))
|
||||
|
||||
if got := recordedRecoveryCleanupCount(t, targetPath); got != 0 {
|
||||
t.Fatalf("recorded cleanup entries = %d after the copy vanished, want the record retired", got)
|
||||
}
|
||||
}
|
||||
|
||||
func recordedRecoveryCleanupCount(t *testing.T, targetPath string) int {
|
||||
t.Helper()
|
||||
return len(loadRecoveryCleanupQueue(targetPath).Records)
|
||||
}
|
||||
|
||||
func TestInstallBinaryBoundsRecoveryCopiesAcrossRepeatedUpgrades(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
|
||||
for version := 1; version <= 4; version++ {
|
||||
contents := fmt.Sprintf("version-%d", version)
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte(contents), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
if err := installBinary(sourcePath, targetPath); err != nil {
|
||||
t.Fatalf("installBinary version %d: %v", version, err)
|
||||
}
|
||||
recoveries, err := existingRecoveryPaths(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("existingRecoveryPaths: %v", err)
|
||||
}
|
||||
if len(recoveries) != 1 {
|
||||
t.Fatalf("recovery count after version %d = %d (%v), want 1", version, len(recoveries), recoveries)
|
||||
}
|
||||
previous, err := os.ReadFile(recoveries[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile recovery after version %d: %v", version, err)
|
||||
}
|
||||
wantPrevious := fmt.Sprintf("version-%d", version-1)
|
||||
if string(previous) != wantPrevious {
|
||||
t.Fatalf("recovery after version %d = %q, want %q", version, previous, wantPrevious)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallBinaryRetainsLockedCleanupRecordUntilLaterRetry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
install := func(version string) {
|
||||
source := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(source, []byte(version), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := installBinary(source, targetPath); err != nil {
|
||||
t.Fatalf("install %s: %v", version, err)
|
||||
}
|
||||
}
|
||||
install("version-1")
|
||||
recoveries, _ := existingRecoveryPaths(targetPath)
|
||||
lockedPath, err := windows.UTF16PtrFromString(recoveries[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked, err := windows.CreateFile(lockedPath, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
install("version-2")
|
||||
_ = windows.CloseHandle(locked)
|
||||
install("version-3")
|
||||
recoveries, _ = existingRecoveryPaths(targetPath)
|
||||
if len(recoveries) != 1 {
|
||||
t.Fatalf("recovery backlog after lock clears = %v, want only newest recovery", recoveries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRestoresOriginalObjectWhenAsidePathIsSubstituted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("known-good"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staged, err := createStagedBinary(targetPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer staged.discard()
|
||||
if _, err := staged.file.WriteString("new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalRename := renameFileByHandle
|
||||
originalRecoveryRename := renameRecoveryFileByHandle
|
||||
var substitutionErr error
|
||||
call := 0
|
||||
renameRecoveryFileByHandle = func(file *os.File, path string) error {
|
||||
call++
|
||||
if err := originalRecoveryRename(file, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if call == 1 {
|
||||
substitutionErr = os.Rename(path, path+".stolen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
renameFileByHandle = func(_ *os.File, _ string) error {
|
||||
return errors.New("injected promotion failure")
|
||||
}
|
||||
defer func() {
|
||||
renameFileByHandle = originalRename
|
||||
renameRecoveryFileByHandle = originalRecoveryRename
|
||||
}()
|
||||
if err := staged.promote(targetPath); err == nil {
|
||||
t.Fatal("promote succeeded despite injected failure")
|
||||
}
|
||||
contents, err := os.ReadFile(targetPath)
|
||||
if err != nil || string(contents) != "known-good" {
|
||||
t.Fatalf("restored target = %q, %v; want original object", contents, err)
|
||||
}
|
||||
if substitutionErr == nil {
|
||||
t.Fatal("aside pathname substitution succeeded while recovery handle was retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallBinaryRefusesRelocatedRecoveryCopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
recoveryPath := targetPath + ".old.deadbeef.recovery"
|
||||
if err := os.WriteFile(recoveryPath, []byte("last-verified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile recovery: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-release"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), recoveryPath) {
|
||||
t.Fatalf("installBinary error = %v, want recovery path %s", err, recoveryPath)
|
||||
}
|
||||
if got, readErr := os.ReadFile(targetPath); readErr != nil || string(got) != "unverified" {
|
||||
t.Fatalf("target = %q err=%v, want unchanged unverified bytes", got, readErr)
|
||||
}
|
||||
if got, readErr := os.ReadFile(recoveryPath); readErr != nil || string(got) != "last-verified" {
|
||||
t.Fatalf("recovery = %q err=%v, want last-verified", got, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallBinaryRefusesRecoveryRelocatedFromRandomizedAside(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
recoveryPath := targetPath + ".aside.old.relocation.recovery"
|
||||
if err := os.WriteFile(recoveryPath, []byte("last-verified"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile recovery: %v", err)
|
||||
}
|
||||
sourcePath := filepath.Join(t.TempDir(), "new-binary")
|
||||
if err := os.WriteFile(sourcePath, []byte("verified-release"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile source: %v", err)
|
||||
}
|
||||
|
||||
err := installBinary(sourcePath, targetPath)
|
||||
if !errors.Is(err, ErrTargetPossiblyTampered) {
|
||||
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
|
||||
}
|
||||
if got, readErr := os.ReadFile(recoveryPath); readErr != nil || string(got) != "last-verified" {
|
||||
t.Fatalf("recovery = %q err=%v, want last-verified", got, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotionLockSerializesSameTarget(t *testing.T) {
|
||||
targetPath := filepath.Join(t.TempDir(), "zero.exe")
|
||||
releaseFirst, err := acquirePromotionLock(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire first promotion lock: %v", err)
|
||||
}
|
||||
|
||||
started := make(chan struct{})
|
||||
acquired := make(chan struct{})
|
||||
releaseSecond := make(chan struct{})
|
||||
secondResult := make(chan error, 1)
|
||||
go func() {
|
||||
close(started)
|
||||
release, err := acquirePromotionLock(strings.ToUpper(targetPath))
|
||||
if err != nil {
|
||||
secondResult <- err
|
||||
return
|
||||
}
|
||||
close(acquired)
|
||||
<-releaseSecond
|
||||
release()
|
||||
secondResult <- nil
|
||||
}()
|
||||
<-started
|
||||
select {
|
||||
case <-acquired:
|
||||
releaseFirst()
|
||||
close(releaseSecond)
|
||||
<-secondResult
|
||||
t.Fatal("second promotion acquired the same target lock before release")
|
||||
case err := <-secondResult:
|
||||
releaseFirst()
|
||||
t.Fatalf("acquire second promotion lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
releaseFirst()
|
||||
select {
|
||||
case <-acquired:
|
||||
close(releaseSecond)
|
||||
if err := <-secondResult; err != nil {
|
||||
t.Fatalf("release second promotion lock: %v", err)
|
||||
}
|
||||
case err := <-secondResult:
|
||||
t.Fatalf("acquire second promotion lock: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("second promotion did not acquire the target lock after release")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallBinaryCleansUpWhenStagingFails covers the cleanup ordering: a
|
||||
// failure after the staging file exists must not leave it behind, because each
|
||||
// attempt now uses a fresh random name that the next attempt never reuses.
|
||||
func TestInstallBinaryCleansUpWhenStagingFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
|
||||
if err := installBinary(filepath.Join(t.TempDir(), "missing-source"), targetPath); err == nil {
|
||||
t.Fatal("installBinary with an unreadable source succeeded, want error")
|
||||
}
|
||||
assertNoStagingLeftovers(t, dir)
|
||||
}
|
||||
|
||||
// TestDiscardDeletesTheStagedObjectThroughItsHandle pins that failure-path
|
||||
// cleanup removes the object this updater staged rather than re-resolving the
|
||||
// staging name after the handle is released.
|
||||
func TestDiscardDeletesTheStagedObjectThroughItsHandle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
staged, err := createStagedBinary(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("createStagedBinary: %v", err)
|
||||
}
|
||||
stagingPath := staged.path
|
||||
|
||||
staged.discard()
|
||||
|
||||
if _, err := os.Lstat(stagingPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("staged object survived discard: %v", err)
|
||||
}
|
||||
assertNoStagingLeftovers(t, dir)
|
||||
}
|
||||
|
||||
// TestDiscardLeavesASubstitutedStagingEntryAlone is the impostor-survival
|
||||
// counterpart of the POSIX promote tests: cleanup is bound to the staged
|
||||
// object, so an entry a principal who can write the installation directory
|
||||
// plants at the staging name once the handle is gone is never deleted. The
|
||||
// substitution is staged by hand here because the exclusive, no-share handle
|
||||
// makes it impossible while the updater still holds the object.
|
||||
func TestDiscardLeavesASubstitutedStagingEntryAlone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
staged, err := createStagedBinary(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("createStagedBinary: %v", err)
|
||||
}
|
||||
stagingPath := staged.path
|
||||
// Run the handle-bound half of discard, then let the substitution win the
|
||||
// race that a pathname-based removal would lose.
|
||||
staged.discardOpenObject()
|
||||
if err := staged.file.Close(); err != nil {
|
||||
t.Fatalf("Close staged handle: %v", err)
|
||||
}
|
||||
const impostorContent = "attacker-owned file that cleanup must not delete"
|
||||
if err := os.WriteFile(stagingPath, []byte(impostorContent), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile impostor: %v", err)
|
||||
}
|
||||
|
||||
staged.discardPaths()
|
||||
|
||||
got, err := os.ReadFile(stagingPath)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup removed the substituted staging entry: %v", err)
|
||||
}
|
||||
if string(got) != impostorContent {
|
||||
t.Fatalf("substituted entry = %q, want it left untouched", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import "testing"
|
||||
|
||||
// stubRandomStagingSuffix overrides randomStagingSuffix to always return a
|
||||
// fixed value for the duration of t, restoring the original on cleanup.
|
||||
// stagingFilePath's random suffix is unpredictable by design in production, so
|
||||
// the Windows staging tests that need the exact path pin it here instead. POSIX
|
||||
// stages inside a private directory and has no such name to pin.
|
||||
func stubRandomStagingSuffix(t *testing.T, suffix string) {
|
||||
t.Helper()
|
||||
original := randomStagingSuffix
|
||||
randomStagingSuffix = func() (string, error) { return suffix, nil }
|
||||
t.Cleanup(func() { randomStagingSuffix = original })
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubStageBinaryFailure makes stageBinary fail for targetPath (matched by base
|
||||
// name, since installBinary is called with the real install path) and behave
|
||||
// normally for everything else, restoring the original on cleanup.
|
||||
//
|
||||
// The staging location is created exclusively under an unpredictable name — a
|
||||
// private directory on POSIX — so a test can no more occupy it than an attacker
|
||||
// can. This seam is how a staging failure is exercised instead.
|
||||
func stubStageBinaryFailure(t *testing.T, targetPath string, failure error) {
|
||||
t.Helper()
|
||||
original := stageBinary
|
||||
want := filepath.Base(targetPath)
|
||||
stageBinary = func(sourcePath string, target string) (*stagedBinary, error) {
|
||||
if filepath.Base(target) == want {
|
||||
return nil, failure
|
||||
}
|
||||
return original(sourcePath, target)
|
||||
}
|
||||
t.Cleanup(func() { stageBinary = original })
|
||||
}
|
||||
|
||||
// assertNoStagingLeftovers fails when dir still holds a platform staging
|
||||
// artifact. Checking both patterns is harmless and keeps this test helper
|
||||
// consistent across POSIX and Windows.
|
||||
func assertNoStagingLeftovers(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir %s: %v", dir, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".zero-stage-") || strings.HasSuffix(entry.Name(), ".new") {
|
||||
t.Fatalf("staging leftover survived in the install directory: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createStagedBinary stages beside targetPath under an unpredictable name. Unlike
|
||||
// POSIX this needs no private directory: Windows can rename an object through the
|
||||
// very handle it was created with (see promote), so the staging pathname never has
|
||||
// to be re-resolved and cannot be substituted.
|
||||
func createStagedBinary(targetPath string) (*stagedBinary, error) {
|
||||
path, err := stagingFilePath(targetPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := createStagingFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stagedBinary{file: file, path: path}, nil
|
||||
}
|
||||
|
||||
// createStagingFile creates path exclusively and without following any
|
||||
// reparse point that may already occupy it. CREATE_NEW alone can still
|
||||
// resolve through an existing reparse point (symlink/junction) when deciding
|
||||
// whether the target exists — if that reparse point's target is a real file
|
||||
// writable by this (possibly elevated) process, CreateFile would open and
|
||||
// truncate it instead. FILE_FLAG_OPEN_REPARSE_POINT makes CreateFile operate
|
||||
// on the reparse point itself, so CREATE_NEW fails on it exactly like it
|
||||
// would fail on a pre-existing regular file or hard link.
|
||||
//
|
||||
// DELETE access is requested alongside GENERIC_WRITE because promote renames
|
||||
// this object through the handle, which requires it.
|
||||
func createStagingFile(path string) (*os.File, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathPtr,
|
||||
windows.GENERIC_WRITE|windows.DELETE,
|
||||
0,
|
||||
nil,
|
||||
windows.CREATE_NEW,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
if err := verifyFreshRegularFile(handle, path); err != nil {
|
||||
// Delete through the handle rather than by pathname: the object this
|
||||
// process just created is the only thing it may remove, and its name is
|
||||
// writable by the threat principal the moment the handle is released.
|
||||
_ = deleteFileByHandle(file)
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// verifyFreshRegularFile defends in depth against the handle unexpectedly
|
||||
// referring to a reparse point, directory, or an object with other hard
|
||||
// links: CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT should already guarantee
|
||||
// a brand-new regular file, but this catches any surprise before any of the
|
||||
// verified release bytes are written through the handle.
|
||||
func verifyFreshRegularFile(handle windows.Handle, path string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("stat new staging file %s: %w", path, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("staging file %s is unexpectedly a reparse point", path)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
return fmt.Errorf("staging file %s is unexpectedly a directory", path)
|
||||
}
|
||||
if info.NumberOfLinks > 1 {
|
||||
return fmt.Errorf("staging file %s unexpectedly has %d hard links", path, info.NumberOfLinks)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// promote makes the staged object the installed binary. Windows will not let a
|
||||
// running executable be overwritten or deleted directly, but NTFS does allow
|
||||
// renaming it aside — the same trick already used for locked config files in
|
||||
// internal/cli/mcp_config.go's replaceMCPWritableConfigFile.
|
||||
//
|
||||
// The second rename goes through the staging HANDLE
|
||||
// (SetFileInformationByHandle/FileRenameInfo) instead of the staging pathname.
|
||||
// A pathname rename would re-resolve the staging entry, so a lower-privileged
|
||||
// principal that can write in the installation directory could replace that entry
|
||||
// after the verified bytes were written and have the updater install its file
|
||||
// instead. Renaming the object the handle already refers to removes that handoff:
|
||||
// there is no second lookup to win.
|
||||
func (staged *stagedBinary) promote(targetPath string) error {
|
||||
releasePromotionLock, err := acquirePromotionLock(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock binary promotion: %w", err)
|
||||
}
|
||||
defer releasePromotionLock()
|
||||
|
||||
if err := preflightRecoveryStateLocked(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Always use a namespaced unpredictable aside path. Besides avoiding any
|
||||
// existing recovery copy, this gives trusted cleanup state a narrow path
|
||||
// format to validate instead of accepting arbitrary *.old files.
|
||||
suffix, suffixErr := randomStagingSuffix()
|
||||
if suffixErr != nil {
|
||||
return fmt.Errorf("choose recovery path: %w", suffixErr)
|
||||
}
|
||||
asidePath := targetPath + ".zero-update-" + suffix + ".old"
|
||||
cleanupCandidates := prepareRecoveryCleanup(targetPath)
|
||||
cleanedCandidates := false
|
||||
defer func() {
|
||||
if !cleanedCandidates {
|
||||
closeRecoveryCleanupCandidates(cleanupCandidates)
|
||||
}
|
||||
}()
|
||||
original, openErr := openRecoveryCopy(targetPath)
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open running binary for recovery: %w", openErr)
|
||||
}
|
||||
defer func() { _ = original.Close() }()
|
||||
originalIdentity, identityErr := recoveryFileIdentity(original)
|
||||
if identityErr != nil {
|
||||
return fmt.Errorf("capture running binary identity: %w", identityErr)
|
||||
}
|
||||
if err := renameRecoveryFileByHandle(original, asidePath); err != nil {
|
||||
return fmt.Errorf("rename running binary aside: %w", err)
|
||||
}
|
||||
if err := verifyPromotedTarget(original, asidePath); err != nil {
|
||||
return fmt.Errorf("verify running binary aside: %w", err)
|
||||
}
|
||||
renameErr := renameFileByHandle(staged.file, targetPath)
|
||||
if renameErr == nil {
|
||||
if verifyErr := verifyPromotedTarget(staged.file, targetPath); verifyErr != nil {
|
||||
renameErr = fmt.Errorf("promoted object unreachable at %s: %w", targetPath, verifyErr)
|
||||
}
|
||||
}
|
||||
if renameErr != nil {
|
||||
if restoreErr := restoreOriginalBinary(original, asidePath, targetPath); restoreErr != nil {
|
||||
return fmt.Errorf("install new binary: %v; additionally failed to restore the original binary: %w", renameErr, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("install new binary: %w", renameErr)
|
||||
}
|
||||
if err := appendRecoveryCleanupRecord(targetPath, asidePath, originalIdentity); err == nil {
|
||||
cleanupSupersededRecoveryCopies(targetPath, cleanupCandidates)
|
||||
cleanedCandidates = true
|
||||
}
|
||||
staged.path = targetPath
|
||||
staged.promoted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightRecoveryState(targetPath string) error {
|
||||
release, err := acquirePromotionLock(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock binary recovery preflight: %w", err)
|
||||
}
|
||||
defer release()
|
||||
return preflightRecoveryStateLocked(targetPath)
|
||||
}
|
||||
|
||||
func preflightRecoveryStateLocked(targetPath string) error {
|
||||
relocatedRecoveries, recoveryErr := relocatedRecoveryPaths(targetPath)
|
||||
if recoveryErr != nil {
|
||||
return fmt.Errorf("%w: inspect relocated recovery state for %s: %v", ErrTargetPossiblyTampered, targetPath, recoveryErr)
|
||||
}
|
||||
if len(relocatedRecoveries) != 0 {
|
||||
return fmt.Errorf(
|
||||
"%w: a previous update moved the last binary this updater verified to %s after recovery-marker creation failed; restore the correct recovery binary or remove it after verifying %s before updating again",
|
||||
ErrTargetPossiblyTampered, strings.Join(relocatedRecoveries, ", "), targetPath,
|
||||
)
|
||||
}
|
||||
// Refuse to promote while a previous failure left its recovery copy in place.
|
||||
//
|
||||
// Skipping the cleanup below is not enough to protect it: os.Rename uses
|
||||
// MOVEFILE_REPLACE_EXISTING on Windows, so renaming the running binary aside
|
||||
// would overwrite the last verified copy with whatever now occupies
|
||||
// targetPath — the very bytes the earlier failure could not verify. If this
|
||||
// promotion then failed, restoreOriginalBinary could only move those
|
||||
// unverified bytes back, and the known-good binary would be gone.
|
||||
//
|
||||
// Automation cannot resolve this safely on the operator's behalf: only they
|
||||
// can say whether the file at targetPath is theirs. So this fails closed and
|
||||
// says exactly which two moves end the state.
|
||||
markedRecoveries, recoveryErr := markedRecoveryPaths(targetPath)
|
||||
if recoveryErr != nil {
|
||||
return fmt.Errorf("%w: inspect previous recovery state for %s: %v", ErrTargetPossiblyTampered, targetPath, recoveryErr)
|
||||
}
|
||||
if len(markedRecoveries) != 0 {
|
||||
recoveryPaths := make([]string, 0, len(markedRecoveries))
|
||||
markerPaths := make([]string, 0, len(markedRecoveries))
|
||||
for _, recoveryPath := range markedRecoveries {
|
||||
recoveryPaths = append(recoveryPaths, recoveryPath)
|
||||
markerPaths = append(markerPaths, recoveryPath+oldBinaryPreservedSuffix)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%w: a previous update could not restore the original binary. Recovery path(s) %s may hold the last binary this updater verified and %s may hold unverified content. "+
|
||||
"Move the correct recovery binary back over %s to restore it, or delete its marker (%s) to accept the installed binary, then update again",
|
||||
ErrTargetPossiblyTampered, strings.Join(recoveryPaths, ", "), targetPath,
|
||||
targetPath, strings.Join(markerPaths, ", "),
|
||||
)
|
||||
}
|
||||
if _, targetErr := os.Lstat(targetPath); errors.Is(targetErr, os.ErrNotExist) {
|
||||
recoveryPaths, err := existingRecoveryPaths(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inspect recovery copies for missing %s: %v", ErrTargetPossiblyTampered, targetPath, err)
|
||||
}
|
||||
switch len(recoveryPaths) {
|
||||
case 1:
|
||||
return fmt.Errorf(
|
||||
"%w: %s is missing and %s may be the only recoverable binary; move it back before updating again",
|
||||
ErrTargetPossiblyTampered, targetPath, recoveryPaths[0],
|
||||
)
|
||||
case 0:
|
||||
// Let the ordinary aside rename below report the missing target.
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"%w: %s is missing and multiple recovery binaries exist (%s); resolve the ambiguous recovery state before updating again",
|
||||
ErrTargetPossiblyTampered, targetPath, strings.Join(recoveryPaths, ", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// acquirePromotionLock serializes the full target-specific recovery transaction
|
||||
// across updater processes. Without it, one updater could capture another's
|
||||
// unmarked aside while that process is still trying to restore or mark it.
|
||||
// Windows mutex ownership is thread-affine, so the caller remains pinned until
|
||||
// the returned release function runs.
|
||||
func acquirePromotionLock(targetPath string) (func(), error) {
|
||||
absolutePath, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(absolutePath))))
|
||||
name, err := windows.UTF16PtrFromString(fmt.Sprintf("Local\\zero-update-%x", digest))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runtime.LockOSThread()
|
||||
handle, createErr := windows.CreateMutex(nil, false, name)
|
||||
if createErr != nil && !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) {
|
||||
runtime.UnlockOSThread()
|
||||
return nil, createErr
|
||||
}
|
||||
if handle == 0 {
|
||||
runtime.UnlockOSThread()
|
||||
return nil, fmt.Errorf("create target mutex returned an invalid handle")
|
||||
}
|
||||
event, waitErr := windows.WaitForSingleObject(handle, windows.INFINITE)
|
||||
if waitErr != nil || event != windows.WAIT_OBJECT_0 && event != windows.WAIT_ABANDONED {
|
||||
_ = windows.CloseHandle(handle)
|
||||
runtime.UnlockOSThread()
|
||||
if waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
return nil, fmt.Errorf("wait for target mutex returned %#x", event)
|
||||
}
|
||||
return func() {
|
||||
_ = windows.ReleaseMutex(handle)
|
||||
_ = windows.CloseHandle(handle)
|
||||
runtime.UnlockOSThread()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// existingRecoveryPaths returns every canonical or randomized aside path that
|
||||
// could hold a binary moved away from targetPath by a previous promotion. The
|
||||
// directory is attacker-writable under the threat model, so these names are
|
||||
// only reasons to fail closed; their presence is never proof of file contents.
|
||||
func existingRecoveryPaths(targetPath string) ([]string, error) {
|
||||
dir := filepath.Dir(targetPath)
|
||||
base := filepath.Base(targetPath)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// NTFS is case-insensitive (case-preserving), so a recovery file can exist
|
||||
// on disk as e.g. "zero.exe.OLD" — fold both sides before matching, or it
|
||||
// silently drops out of every caller's fail-closed check below.
|
||||
lowerBase := strings.ToLower(base)
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
lowerName := strings.ToLower(name)
|
||||
if lowerName == lowerBase+".old" ||
|
||||
(strings.HasPrefix(lowerName, lowerBase+".") &&
|
||||
strings.HasSuffix(lowerName, ".old") &&
|
||||
len(lowerName) > len(lowerBase)+len("..old")) {
|
||||
paths = append(paths, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// relocatedRecoveryPaths returns copies moved to the distinct recovery name
|
||||
// after a failed restore could not establish a .keep marker. Unlike ordinary
|
||||
// unmarked .old files, these are authoritative unresolved recovery state and
|
||||
// must block every later promotion until the operator resolves them.
|
||||
func relocatedRecoveryPaths(targetPath string) ([]string, error) {
|
||||
dir := filepath.Dir(targetPath)
|
||||
base := strings.ToLower(filepath.Base(targetPath))
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := base + "."
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
lowerName := strings.ToLower(name)
|
||||
if !strings.HasPrefix(lowerName, prefix) || !strings.HasSuffix(lowerName, ".recovery") {
|
||||
continue
|
||||
}
|
||||
// The canonical relocation is <target>.old.<suffix>.recovery.
|
||||
// A failed promotion that already used a randomized aside can also
|
||||
// produce <target>.<aside-suffix>.old.<recovery-suffix>.recovery.
|
||||
middle := strings.TrimSuffix(strings.TrimPrefix(lowerName, prefix), ".recovery")
|
||||
if strings.HasPrefix(middle, "old.") && len(middle) > len("old.") ||
|
||||
strings.Contains(middle, ".old.") {
|
||||
paths = append(paths, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// markedRecoveryPaths finds recovery copies protected by either the canonical
|
||||
// marker or a marker beside a randomized aside path. A failed second-or-later
|
||||
// update commonly uses the latter because the canonical .old already exists.
|
||||
func markedRecoveryPaths(targetPath string) ([]string, error) {
|
||||
recoveryPaths, err := existingRecoveryPaths(targetPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var marked []string
|
||||
for _, recoveryPath := range recoveryPaths {
|
||||
if oldBinaryPreserved(recoveryPath) {
|
||||
marked = append(marked, recoveryPath)
|
||||
}
|
||||
}
|
||||
return marked, nil
|
||||
}
|
||||
|
||||
// verifyPromotedTarget reports whether targetPath names the same object as the
|
||||
// staged handle after a reported-successful rename. SetFileInformationByHandle
|
||||
// returning success is not, on its own, proof the object ended up there: a
|
||||
// handle whose directory entry was removed and replaced out from under it
|
||||
// (the substitution createStagingFile's exclusive, no-share open defends
|
||||
// against — see its doc comment) can leave some Windows versions accepting
|
||||
// the rename call without it taking effect, which would otherwise let
|
||||
// promote report success while targetPath is left missing entirely.
|
||||
//
|
||||
// A metadata-only open is not blocked by staged.file's exclusive data/delete
|
||||
// sharing. Comparing the volume and file index is independent of path spelling,
|
||||
// including 8.3 names, case, UNC prefixes, and reparse points in ancestors.
|
||||
func verifyPromotedTarget(file *os.File, targetPath string) error {
|
||||
targetPathPtr, err := windows.UTF16PtrFromString(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetHandle, err := windows.CreateFile(
|
||||
targetPathPtr,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open promoted target metadata: %w", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(targetHandle) }()
|
||||
|
||||
var stagedInfo windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &stagedInfo); err != nil {
|
||||
return fmt.Errorf("query staged object identity: %w", err)
|
||||
}
|
||||
var targetInfo windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(targetHandle, &targetInfo); err != nil {
|
||||
return fmt.Errorf("query promoted target identity: %w", err)
|
||||
}
|
||||
if targetInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("promoted target is unexpectedly a reparse point")
|
||||
}
|
||||
if targetInfo.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
return fmt.Errorf("promoted target is unexpectedly a directory")
|
||||
}
|
||||
if stagedInfo.NumberOfLinks != 1 || targetInfo.NumberOfLinks != 1 {
|
||||
return fmt.Errorf("promoted object unexpectedly has %d hard links", targetInfo.NumberOfLinks)
|
||||
}
|
||||
if stagedInfo.VolumeSerialNumber != targetInfo.VolumeSerialNumber ||
|
||||
stagedInfo.FileIndexHigh != targetInfo.FileIndexHigh ||
|
||||
stagedInfo.FileIndexLow != targetInfo.FileIndexLow {
|
||||
return fmt.Errorf("target path does not name the staged object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardOpenObject schedules the staged object for deletion through the handle
|
||||
// it was created with, so failure-path cleanup removes exactly the object this
|
||||
// updater staged. The pathname alternative (os.Remove after the handle is
|
||||
// closed) re-resolves the staging entry, and a principal who can write in the
|
||||
// installation directory can substitute that entry in the gap — the same
|
||||
// handoff createStagingFile and promote are built to avoid. Deletion takes
|
||||
// effect when the last handle closes, which discard does immediately after.
|
||||
//
|
||||
// If the request fails, the staged file is left behind rather than removed by
|
||||
// name: a leaked file costs disk, deleting an attacker-chosen entry costs the
|
||||
// operator a file they own.
|
||||
func (staged *stagedBinary) discardOpenObject() {
|
||||
if staged.promoted || staged.file == nil {
|
||||
return
|
||||
}
|
||||
_ = deleteFileByHandle(staged.file)
|
||||
}
|
||||
|
||||
func (staged *stagedBinary) discardPaths() {
|
||||
// POSIX-only state is present in the shared struct and always nil here.
|
||||
_ = staged.dir
|
||||
_ = staged.dirHandle
|
||||
_ = staged.parentHandle
|
||||
// Nothing is removed by pathname here; see discardOpenObject.
|
||||
}
|
||||
|
||||
// fileRenameInfo mirrors FILE_RENAME_INFO. FileName is a variable-length WCHAR
|
||||
// array that follows the header, so the buffer is sized by hand and the name is
|
||||
// appended after fileRenameInfoHeaderSize bytes.
|
||||
type fileRenameInfo struct {
|
||||
ReplaceIfExists bool
|
||||
RootDirectory windows.Handle
|
||||
FileNameLength uint32
|
||||
}
|
||||
|
||||
// fileRenameInfoHeaderSize is the offset of FILE_RENAME_INFO's FileName member.
|
||||
// It is derived rather than hardcoded because the RootDirectory pointer's
|
||||
// alignment (and therefore the offset) differs between 32- and 64-bit Windows.
|
||||
var fileRenameInfoHeaderSize = func() uintptr {
|
||||
var info fileRenameInfo
|
||||
return unsafe.Offsetof(info.FileNameLength) + unsafe.Sizeof(info.FileNameLength)
|
||||
}()
|
||||
|
||||
// renameFileByHandle renames the object file refers to, not the object its
|
||||
// current pathname resolves to. targetPath must be fully qualified.
|
||||
//
|
||||
// It is a package var, like stageBinary, so a test can simulate
|
||||
// SetFileInformationByHandle reporting success without the rename actually
|
||||
// taking effect — the exact failure mode verifyPromotedTarget defends
|
||||
// against — without needing to reproduce whatever Windows-version-specific
|
||||
// condition triggers it for real.
|
||||
func renameOpenFile(file *os.File, targetPath string) error {
|
||||
name, err := windows.UTF16FromString(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// FILE_RENAME_INFO declares FileName as NUL-terminated even though
|
||||
// FileNameLength excludes the terminator. Keep the terminator in the buffer:
|
||||
// omitting it makes the ordinary rename fail with ERROR_PATH_NOT_FOUND on the
|
||||
// supported Windows runner.
|
||||
buffer := make([]byte, int(fileRenameInfoHeaderSize)+len(name)*2)
|
||||
info := (*fileRenameInfo)(unsafe.Pointer(&buffer[0]))
|
||||
// ReplaceIfExists stays false: promote already renamed the running binary
|
||||
// aside, so a target that exists again means something raced the update, and
|
||||
// failing is better than clobbering whatever appeared there.
|
||||
info.FileNameLength = uint32((len(name) - 1) * 2)
|
||||
for index, unit := range name {
|
||||
binary.LittleEndian.PutUint16(buffer[int(fileRenameInfoHeaderSize)+index*2:], unit)
|
||||
}
|
||||
if err := windows.SetFileInformationByHandle(
|
||||
windows.Handle(file.Fd()),
|
||||
windows.FileRenameInfo,
|
||||
&buffer[0],
|
||||
uint32(len(buffer)),
|
||||
); err != nil {
|
||||
return fmt.Errorf("rename staged binary onto %s: %w", targetPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var renameFileByHandle = renameOpenFile
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateStagingFileRefusesPrecreatedHardLink is the regression test for
|
||||
// #742: a lower-privileged attacker who can write in the installation
|
||||
// directory pre-creates the staging path as a hard link to another file the
|
||||
// (possibly elevated) updater can write. createStagingFile must fail instead
|
||||
// of opening and truncating through that link.
|
||||
func TestCreateStagingFileRefusesPrecreatedHardLink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
victim := filepath.Join(dir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile victim: %v", err)
|
||||
}
|
||||
staged := filepath.Join(dir, "staged")
|
||||
if err := os.Link(victim, staged); err != nil {
|
||||
t.Fatalf("Link: %v", err)
|
||||
}
|
||||
|
||||
if _, err := createStagingFile(staged); err == nil {
|
||||
t.Fatal("createStagingFile succeeded through a pre-existing hard link, want error")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(victim)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile victim: %v", err)
|
||||
}
|
||||
if string(data) != "do not touch" {
|
||||
t.Fatalf("victim content = %q, want unchanged", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStagingFileRefusesPrecreatedSymlink covers the reparse-point
|
||||
// variant of the same #742 primitive. Creating a file symlink on Windows
|
||||
// needs SeCreateSymbolicLinkPrivilege (admin) or Developer Mode; skip rather
|
||||
// than fail where that isn't available.
|
||||
func TestCreateStagingFileRefusesPrecreatedSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
victim := filepath.Join(dir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile victim: %v", err)
|
||||
}
|
||||
staged := filepath.Join(dir, "staged")
|
||||
if err := os.Symlink(victim, staged); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
if _, err := createStagingFile(staged); err == nil {
|
||||
t.Fatal("createStagingFile succeeded through a pre-existing symlink, want error")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(victim)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile victim: %v", err)
|
||||
}
|
||||
if string(data) != "do not touch" {
|
||||
t.Fatalf("victim content = %q, want unchanged", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStagingFileSucceedsForFreshPath is the control: a path with
|
||||
// nothing pre-existing must still work normally.
|
||||
func TestCreateStagingFileSucceedsForFreshPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "staged")
|
||||
|
||||
file, err := createStagingFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("createStagingFile: %v", err)
|
||||
}
|
||||
if _, err := file.WriteString("payload"); err != nil {
|
||||
t.Fatalf("WriteString: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "payload" {
|
||||
t.Fatalf("content = %q, want %q", data, "payload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStagingFileConcurrentRaceOnlyOneWinner exercises a race on a
|
||||
// single fixed path (installBinary normally avoids this by randomizing the
|
||||
// name, but the exclusive-creation guarantee must hold regardless): exactly
|
||||
// one concurrent caller may create the file, and the rest must fail cleanly
|
||||
// rather than silently truncate the winner's content.
|
||||
func TestCreateStagingFileConcurrentRaceOnlyOneWinner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "staged")
|
||||
|
||||
const attempts = 16
|
||||
var wg sync.WaitGroup
|
||||
successes := make([]bool, attempts)
|
||||
for i := range attempts {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
file, err := createStagingFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
successes[i] = true
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
winners := 0
|
||||
for _, ok := range successes {
|
||||
if ok {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("concurrent createStagingFile winners = %d, want exactly 1", winners)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// randomStagingSuffix returns hex-encoded random bytes for stagingFilePath.
|
||||
// Overridden in tests for a deterministic path; production always takes this
|
||||
// default, cryptographically random one.
|
||||
var randomStagingSuffix = func() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// stagingFilePath returns an unpredictable path in targetPath's directory
|
||||
// (same filesystem, so the later rename into place is atomic). A fixed
|
||||
// "<target>.new" name is guessable in advance, and a lower-privileged
|
||||
// process that can write in the installation directory could pre-create it
|
||||
// as a hard link or reparse point to another file the elevated updater can
|
||||
// write, turning the staging copy into an arbitrary-file-overwrite primitive.
|
||||
// createStagingFile's exclusive, no-follow creation is the other half of
|
||||
// closing that: even a correctly-guessed name can't be opened through.
|
||||
func stagingFilePath(targetPath string) (string, error) {
|
||||
suffix, err := randomStagingSuffix()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate staging file name: %w", err)
|
||||
}
|
||||
name := filepath.Base(targetPath) + "." + suffix + ".new"
|
||||
return filepath.Join(filepath.Dir(targetPath), name), nil
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
|
||||
request := CompletionRequest{
|
||||
Messages: []Message{{Role: MessageRoleUser, Content: "hi"}},
|
||||
ReasoningEffort: "high",
|
||||
ServiceTier: "priority",
|
||||
PromptCacheKey: "session-123",
|
||||
}
|
||||
stream, err := session.Stream(context.Background(), request)
|
||||
@@ -60,6 +61,7 @@ func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
|
||||
recorded := provider.requests[0]
|
||||
if recorded.PromptCacheKey != request.PromptCacheKey ||
|
||||
recorded.ReasoningEffort != request.ReasoningEffort ||
|
||||
recorded.ServiceTier != request.ServiceTier ||
|
||||
len(recorded.Messages) != 1 || recorded.Messages[0].Content != "hi" {
|
||||
t.Fatalf("request not forwarded verbatim: %#v", recorded)
|
||||
}
|
||||
|
||||
@@ -205,6 +205,10 @@ type CompletionRequest struct {
|
||||
// Anthropic/Gemini thinking budgets) and ignores it for models that do not
|
||||
// support reasoning. Empty means "let the provider decide".
|
||||
ReasoningEffort string
|
||||
// ServiceTier selects an optional provider-advertised request tier. Empty
|
||||
// means the provider default; "priority" is the wire value used by fast mode.
|
||||
// Callers must only set a tier advertised for the active provider/model.
|
||||
ServiceTier string
|
||||
// PromptCacheKey, when non-empty, is an opaque stable identifier for the
|
||||
// conversation (the session ID). Providers with server-side prefix-cache
|
||||
// routing forward it — OpenAI `prompt_cache_key` — so consecutive requests
|
||||
|
||||
Reference in new issue
Block a user