Compare commits

..
Author SHA1 Message Date
anandh8x d195972df5 Keep Sixel companions in the reserved dock 2026-08-10 21:47:45 +05:30
Vasanthdev2004 cbb4a64ffd fix(tui): ask sixel terminals for their cell size and erase what was painted
The companion blanks a row and a column of live interface on Windows Terminal.
The cause is one predicate.

petPixelProtocolSupported gates two unrelated questions that only looked like
one: whether pixel-precise dragging is available, and whether to ask the
terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on
a sixel terminal the request was never sent, the reply never arrived, and
petCellPixelWidth/Height stayed zero for the whole session.

Everything downstream then took its fallback. petImageHeightPixels returned its
blind preferredHeight of 75 pixels instead of the cell-aware branch below it,
and the erase in clearRenderedImage used petImageColumns and petImageRows. Those
constants describe the RESERVED area, not what was painted. Windows Terminal
reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns
while the erase blanked 5 by 9, taking a row and a column of interface with it
on every move. A drag repaints per pixel, so it did that continuously.

Windows Terminal answers CSI 16 t, confirmed by hand: it replies
ESC [6;20;10t. This was never a question of terminal support. Nothing asked.

So the request now goes out for every image protocol, via a predicate separate
from the dragging one, and the sixel footprint is computed from the rendered
pixel height and the reported cell size rather than from the constants. It is
still clamped to the reserved area, because the layout keeps that many cells
clear and the drag clamps to it, so a larger computed footprint would mean
erasing into live interface.

Deliberately NOT widening petPixelProtocolSupported itself: that would enable
pixel-precise dragging on sixel as a side effect, which is a separate feature
decision rather than part of this defect.

Kitty is untouched and a test pins that. There Columns and Rows are the
placement REQUEST handed to the terminal, which owns the region and scales into
it, so recomputing them would resize the image rather than fix an erase.

Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty
keeps its requested placement, the fallback without metrics stays the reserved
area rather than collapsing to zero and erasing nothing, and every image
protocol asks for the measurement.
2026-08-10 20:26:06 +05:30
Vasanthdev2004 0c4a1f2cf6 fix(terminalpet): reset SGR before erasing a sixel companion
On Windows Terminal the companion draws a filled black rectangle over the
interface, and dragging it mangles text under and around it. It looks like a
platform quirk. It is an untested code path that only Windows reaches.

Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands
the terminal an image id and the placement goes away. A sixel is already in the
cell grid, so clearRenderedImage writes literal spaces over the cells it
occupied. Those spaces are ordinary characters and carry whatever SGR the TUI
last set, and nothing normalised it, so every erase stamped a filled block in
the ambient background. A drag erases and repaints on every pixel, so the block
was continuously refreshed over the UI.

It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm
all resolve to the Kitty protocol and delete by id, never reaching this code.
Windows Terminal is the only mainstream terminal that lands on the sixel branch,
which is why testing on Linux and macOS could not have caught it.

The erase now resets SGR before painting, and uses DECSC/DECRC rather than
CSI s/u because only DECSC saves the attributes this clobbers. Restoring them
matters: the erase is emitted mid-frame, between styled writes the TUI has
already begun.

Measured before and after rather than reasoned about. Before:

  \x1b[s\x1b[3;41H      \x1b[4;41H      \x1b[5;41H      \x1b[u

no reset anywhere. After, the reset leads and DECRC closes.

Tests assert the bytes, since bytes are what the terminal sees: SGR is reset
BEFORE the first row is painted rather than merely present somewhere, the erase
still covers every claimed cell so the fix cannot be satisfied by erasing
nothing, and the Kitty path still deletes by id rather than acquiring the
character-painting erase. Verified by mutation: restoring the old sequence fails
the first of those with the exact reported symptom.

Deliberately not fixed here, since both are design calls rather than defects:
the erase rectangle is computed from the app's own cell maths rather than the
terminal's real footprint, and the sixel path has no in-place move, so every
drag pixel is a full erase and repaint where Kitty gets a cheap reposition.
2026-08-10 20:09:14 +05:30
anandh8x c26a97da26 Handle sparse pet preference layouts 2026-08-10 17:56:30 +05:30
anandh8x 3c784e716a Preserve config layout when selecting pets 2026-08-10 17:42:41 +05:30
anandh8x 581f29b0ba Strengthen terminal companion regression tests 2026-08-10 16:39:34 +05:30
anandh8x d0fcb2d2b5 Fix terminal companion edge cases 2026-08-10 16:27:56 +05:30
anandh8x da2768dccd Polish interactive terminal companions 2026-08-10 15:48:20 +05:30
anandh8x d8e0267ea5 Add terminal companions 2026-08-10 15:47:41 +05:30
55 changed files with 272 additions and 4532 deletions

No files matched your search

-8
View File
@@ -91,14 +91,6 @@ 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
-40
View File
@@ -70,43 +70,3 @@ 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.
+1 -1
View File
@@ -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, serviceTier: options.ServiceTier}),
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
trace: options.Trace,
}
return state
-2
View File
@@ -9,7 +9,6 @@ import (
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
serviceTier string
promptParts systemPromptParts
}
@@ -63,7 +62,6 @@ 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)
-2
View File
@@ -25,7 +25,6 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
serviceTier: "priority",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
@@ -34,7 +33,6 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
ServiceTier: "priority",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
+6 -17
View File
@@ -144,10 +144,9 @@ 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",
ServiceTier: "priority",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
@@ -168,15 +167,9 @@ 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)
}
@@ -218,10 +211,9 @@ 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",
ServiceTier: "priority",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
@@ -233,9 +225,6 @@ 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)
}
-6
View File
@@ -186,7 +186,6 @@ 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)
@@ -830,11 +829,6 @@ 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
-1
View File
@@ -325,7 +325,6 @@ type Options struct {
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
+48 -2
View File
@@ -1,8 +1,13 @@
package cli
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
@@ -23,5 +28,46 @@ 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) {
return providers.OAuthLoginForProfile(profile)
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
}
-12
View File
@@ -73,18 +73,6 @@ 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{
+1 -4
View File
@@ -29,9 +29,6 @@ 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
@@ -413,7 +410,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax, ReasoningEffortUltra:
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
return true
default:
return false
+11 -17
View File
@@ -8,21 +8,17 @@ import (
)
type Model struct {
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
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
@@ -274,8 +270,6 @@ 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,25 +122,6 @@ 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 {
+11 -54
View File
@@ -230,11 +230,6 @@ 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"`
@@ -314,8 +309,6 @@ 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
@@ -326,56 +319,20 @@ func (model remoteModel) toModel(key string, source string) Model {
}
return Model{
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,
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,
}
}
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" {
+61 -180
View File
@@ -19,27 +19,20 @@ import (
"github.com/Gitlawb/zero/internal/redaction"
)
const (
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
)
const anthropicVersion = "2023-06-01"
type Model struct {
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
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
type Options struct {
@@ -50,7 +43,6 @@ 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) {
@@ -59,7 +51,6 @@ 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)
@@ -213,18 +204,6 @@ 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 {
@@ -347,39 +326,24 @@ 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 []modelsResponseItem `json:"data"`
Models []modelsResponseItem `json:"models"`
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"`
}
func parseModelsResponse(body []byte) ([]Model, error) {
@@ -388,21 +352,12 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return nil, fmt.Errorf("decode models response: %w", err)
}
seen := map[string]bool{}
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)
models := make([]Model, 0, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
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,
@@ -426,19 +381,13 @@ 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 || len(efforts) > 0,
ReasoningEfforts: efforts,
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
Source: "live",
ID: id,
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
Source: "live",
})
}
sort.SliceStable(models, func(i, j int) bool {
@@ -450,50 +399,6 @@ 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,
@@ -511,21 +416,17 @@ 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,
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,
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,
})
}
return result
@@ -539,8 +440,7 @@ 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) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID)
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
if catalog, ok := byID[live.ID]; ok {
@@ -558,21 +458,6 @@ 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
@@ -641,21 +526,17 @@ 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,
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,
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,
}
}
@@ -80,90 +80,6 @@ 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.
@@ -290,9 +206,6 @@ 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 {
-56
View File
@@ -1,56 +0,0 @@
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
}
-68
View File
@@ -1,68 +0,0 @@
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,7 +80,6 @@ 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
@@ -266,9 +265,6 @@ 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
}
-43
View File
@@ -492,49 +492,6 @@ 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
+1 -13
View File
@@ -467,9 +467,6 @@ 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
@@ -508,16 +505,7 @@ 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", "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":
case "minimal", "low", "medium", "high":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
@@ -860,51 +860,6 @@ 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
@@ -1,34 +0,0 @@
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)
}
})
}
}
+2 -4
View File
@@ -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 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
// 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
// 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,8 +175,6 @@ 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))
+1 -8
View File
@@ -231,18 +231,11 @@ 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: "bogus",
ReasoningEffort: "xhigh",
})
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) {
-1
View File
@@ -6,7 +6,6 @@ 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
+1 -1
View File
@@ -105,7 +105,7 @@ Review.`)
_, err = ParseMarkdown(`---
name: reviewer
description: Reviews code
reasoningEffort: IMPOSSIBLE
reasoningEffort: ULTRA
---
Review.`)
if err == nil || !strings.Contains(err.Error(), "unknown reasoning effort") {
-2
View File
@@ -456,7 +456,6 @@ 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
@@ -560,7 +559,6 @@ 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
+1 -9
View File
@@ -33,7 +33,6 @@ const (
commandCompact
commandRewind
commandEffort
commandFast
commandStyle
commandTheme
commandTranscript
@@ -272,18 +271,11 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/effort",
usage: "/effort [list|level|auto]",
usage: "/effort [list|low|medium|high|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]",
+1 -2
View File
@@ -47,8 +47,7 @@ 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|level|auto] - Show or set reasoning effort for supported models.",
" /fast - Toggle fast mode for supported ChatGPT subscription models.",
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
"session:",
" /plan [status|on|off] - Show plan status, or enter/exit read-only planning mode.",
"runtime:",
-97
View File
@@ -1,97 +0,0 @@
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)
}
}
-7
View File
@@ -141,7 +141,6 @@ 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
@@ -4841,11 +4840,6 @@ 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)
@@ -5414,7 +5408,6 @@ 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
-1
View File
@@ -198,7 +198,6 @@ 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"},
+1 -12
View File
@@ -14,7 +14,6 @@ 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.
@@ -408,9 +407,8 @@ 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, discoveryOptions)
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, providermodeldiscovery.Options{})
}
}
providerID := descriptor.ID
@@ -428,15 +426,6 @@ 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
}
-71
View File
@@ -10,7 +10,6 @@ import (
"reflect"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
@@ -53,40 +52,6 @@ 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{
@@ -1144,42 +1109,6 @@ 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")).
-119
View File
@@ -13,8 +13,6 @@ 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"
@@ -86,96 +84,6 @@ 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"
@@ -328,9 +236,6 @@ 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
@@ -338,30 +243,6 @@ 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"
+1 -4
View File
@@ -187,7 +187,7 @@ func (m model) composerDividerLine(width int) string {
}
// statusLine renders the bottom readout as ` │ `-separated groups: the run-state
// chip (permission mode + effort/fast tier) on the left, a flexible gap, then the
// chip (permission mode + effort) 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 {
@@ -225,9 +225,6 @@ 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 {
+31 -123
View File
@@ -2,7 +2,6 @@ package update
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -16,15 +15,6 @@ 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.
@@ -46,7 +36,6 @@ 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
@@ -58,23 +47,25 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
return ApplyResult{}, err
}
executablePath, err := currentExecutable()
executablePath, err := os.Executable()
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
}
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
executablePath = resolved
}
method := DetectInstallMethod(executablePath)
if method != InstallMethodNpm {
if err := preflightRecoveryState(executablePath); err != nil {
return ApplyResult{}, err
}
}
// 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)
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 {
@@ -188,6 +179,10 @@ 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)
@@ -199,29 +194,9 @@ 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
}
@@ -243,79 +218,23 @@ func verifyArchiveChecksum(checksumPath string, expectedArchiveName string) erro
return nil
}
// installBinary stages sourcePath next to targetPath (same filesystem, so the
// final rename is atomic) and then swaps it into place.
// installBinary stages sourcePath next to targetPath (same directory, so the
// final rename is atomic/same-filesystem) and then swaps it into place.
func installBinary(sourcePath string, targetPath string) error {
staged, err := stageBinary(sourcePath, targetPath)
if err != nil {
stagedPath := targetPath + ".new"
if err := copyFile(sourcePath, stagedPath); err != nil {
return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
}
// 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 {
defer func() {
_ = os.Remove(stagedPath)
}()
if err := replaceBinary(targetPath, stagedPath); err != nil {
return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err)
}
return nil
}
// 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 {
func copyFile(sourcePath string, destPath string) (retErr error) {
source, err := os.Open(sourcePath)
if err != nil {
return err
@@ -323,28 +242,17 @@ func (staged *stagedBinary) copyFrom(sourcePath string) error {
defer func() {
_ = source.Close()
}()
if _, err := io.Copy(staged.file, source); err != nil {
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
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()
defer func() {
if closeErr := dest.Close(); closeErr != nil && retErr == nil {
retErr = closeErr
}
}()
_, retErr = io.Copy(dest, source)
return retErr
}
func downloadFile(ctx context.Context, url string, destPath string) error {
+8 -105
View File
@@ -2,8 +2,6 @@ package update
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -140,12 +138,9 @@ func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
if entries, err := os.ReadDir(installDir); err == nil {
for _, entry := range entries {
name := entry.Name()
// 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 {
// 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") {
continue
}
t.Fatalf("unexpected extra file left in install dir: %s", name)
@@ -174,11 +169,11 @@ func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %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"))
// 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)
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
@@ -246,98 +241,6 @@ 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" {
-58
View File
@@ -1,58 +0,0 @@
//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)
}
})
}
}
@@ -1,5 +0,0 @@
//go:build !windows
package update
func preflightRecoveryState(string) error { return nil }
+19
View File
@@ -0,0 +1,19 @@
//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) {}
+30 -502
View File
@@ -3,18 +3,9 @@
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 (
@@ -22,16 +13,33 @@ const (
restoreRenameRetryDelay = 100 * time.Millisecond
)
// 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 {
// 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 {
var lastErr error
for attempt := 0; attempt < restoreRenameRetryAttempts; attempt++ {
if err := renameRecoveryFileByHandle(file, newPath); err == nil {
if err := os.Rename(oldPath, newPath); err == nil {
return nil
} else {
lastErr = err
@@ -41,489 +49,9 @@ func renameOpenFileWithRetry(file *os.File, newPath string) error {
return lastErr
}
// 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)
// 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")
}
+36 -441
View File
@@ -3,468 +3,63 @@
package update
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// 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.
func TestReplaceBinaryReplacesRunningBinary(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
newPath := filepath.Join(dir, "zero.exe.new")
func TestRenameOpenFileWithRetrySucceedsImmediately(t *testing.T) {
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) {
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 := renameOpenFileWithRetry(file, dst); err != nil {
t.Fatalf("renameOpenFileWithRetry: %v", err)
if err := renameWithRetry(src, dst); err != nil {
t.Fatalf("renameWithRetry: %v", err)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("expected dst to exist after rename: %v", err)
}
}
// 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) {
// 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) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
missing := filepath.Join(dir, "does-not-exist")
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 := renameOpenFileWithRetry(file, dst); err == nil {
t.Fatal("expected renameOpenFileWithRetry to fail against a permanently blocked destination")
if err := renameWithRetry(missing, dst); err == nil {
t.Fatal("expected renameWithRetry to fail for a source that never appears")
}
}
// 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
}
-225
View File
@@ -1,225 +0,0 @@
//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, &current, 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),
&current,
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()
}
}
-142
View File
@@ -1,142 +0,0 @@
//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)
}
}
-235
View File
@@ -1,235 +0,0 @@
//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)
}
@@ -1,910 +0,0 @@
//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)
}
}
@@ -1,17 +0,0 @@
//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 })
}
-44
View File
@@ -1,44 +0,0 @@
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())
}
}
}
-498
View File
@@ -1,498 +0,0 @@
//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
-130
View File
@@ -1,130 +0,0 @@
//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)
}
}
-38
View File
@@ -1,38 +0,0 @@
//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
}
-2
View File
@@ -40,7 +40,6 @@ 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)
@@ -61,7 +60,6 @@ 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)
}
-4
View File
@@ -205,10 +205,6 @@ 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