Compare commits

..
Author SHA1 Message Date
anandh8x c6b805af1f fix(tools): reject mixed-root freeform patches 2026-08-26 01:18:34 +05:30
anandh8x 5c5d0bf8d0 Merge main into feat/runtime-performance 2026-08-25 21:04:28 +05:30
anandh8x 94ff28f5cb test: keep patch fixtures out of worktree 2026-08-25 11:43:40 +05:30
anandh8x a31b8ea28d fix: bind freeform patch writes to scoped roots 2026-08-25 11:43:40 +05:30
anandh8x 8081d0e194 fix: keep run tools and patch roots consistent 2026-08-25 11:43:39 +05:30
anandh8x e4f922af5e fix: harden turn session regressions 2026-08-25 11:43:39 +05:30
anandh8x 69958aa320 fix(cli): redact MCP registration errors 2026-08-25 11:43:39 +05:30
anandh8x c77557cfaa build: restore official Bubble Tea dependency 2026-08-25 11:43:39 +05:30
anandh8x 2b582f1086 fix: address runtime review findings 2026-08-25 11:43:39 +05:30
anandh8x 70d53c1f57 fix: harden runtime review edge cases 2026-08-25 11:43:39 +05:30
anandh8x 6a7c689155 perf(agent): reduce redundant bounded-task turns 2026-08-25 11:43:39 +05:30
anandh8x 3e4ee001d7 fix(usage): expose cache efficiency details 2026-08-25 11:43:39 +05:30
anandh8x edb6db2070 fix(runtime): address performance review findings 2026-08-25 11:43:39 +05:30
anandh8x 8aa81b7139 perf(tui): render only on screen changes 2026-08-25 11:43:39 +05:30
anandh8x f683c3cdb5 fix(provider): recognize saved chat sessions 2026-08-25 11:43:39 +05:30
anandh8x 54a9ab2371 feat(provider): add native patch tool input 2026-08-25 11:43:39 +05:30
anandh8x b0947b7d8f perf(provider): reuse compatible response sessions 2026-08-25 11:43:39 +05:30
anandh8x c3ae5c4835 perf(startup): defer optional tool discovery 2026-08-25 11:43:39 +05:30
anandh8x e187290b39 refactor(tools): publish immutable registry snapshots 2026-08-25 11:43:39 +05:30
anandh8x a15ce26333 perf(tui): stop composer wakeups after idle 2026-08-25 11:43:39 +05:30
anandh8x 496637bea3 perf(tui): disable duplicate composer cursor blink 2026-08-25 11:43:39 +05:30
152 changed files with 1318 additions and 20544 deletions

No files matched your search

+11 -172
View File
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -398,27 +396,6 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -453,88 +430,17 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -628,34 +534,10 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
if state.reactiveAttempted {
return messages, false, nil
}
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
@@ -695,21 +577,11 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -793,41 +665,8 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -837,13 +676,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
calls = append(calls, call.Name+"("+call.Arguments+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
lines = append(lines, "tool result: "+message.Content)
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
-469
View File
@@ -1,469 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+5 -15
View File
@@ -846,21 +846,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
}
}
}
+4 -20
View File
@@ -348,26 +348,10 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
-1
View File
@@ -974,7 +974,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
+10 -14
View File
@@ -682,20 +682,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
+1 -11
View File
@@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.")
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
}
switch args[0] {
case "-h", "--help", "help":
@@ -36,8 +36,6 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runSandboxSetup(args[1:], stdout, stderr, deps)
case "check":
return runSandboxCheck(args[1:], stdout, stderr, deps)
case "exec":
return runSandboxExec(args[1:], stdout, stderr, deps)
case "grants":
return runSandboxGrants(args[1:], stdout, stderr, deps)
default:
@@ -176,17 +174,10 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
if !setupHelper.Available() {
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
}
// Resolved here, in the shell the user typed `zero sandbox setup` into, and
// carried in the args. The helper may be launched elevated, and an elevated
// process does not inherit this shell's environment. Stated explicitly rather
// than left nil (which resolves the same way) because this is the call site
// the opt-in is about.
principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil)
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
PrincipalOptIn: &principalOptIn,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -655,7 +646,6 @@ Commands:
policy Inspect active sandbox policy and platform backend
setup Run native platform sandbox setup
check Evaluate the sandbox decision for a hypothetical tool action
exec Run one command through the real sandbox
grants Manage persistent sandbox grants
`)
-204
View File
@@ -1,204 +0,0 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/Gitlawb/zero/internal/config"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
// runSandboxExec runs ONE command through the real sandbox and exits with its
// status.
//
// This exists because until now the sandbox could only be exercised through a
// full agent turn with a model in the loop. `zero sandbox policy` reports what
// the posture would be and `zero sandbox check` evaluates a hypothetical
// decision, but nothing actually ran a command and let you look at what
// happened on disk afterwards. The practical result is that enforcement is
// covered almost entirely by tests asserting the shape of an ACL plan, and
// almost not at all by tests asserting a write was refused.
//
// A plan can be perfectly correct and never reach the filesystem. That is not
// hypothetical here: the .git rename guard was emitted correctly by the planner
// and silently skipped by the applier, and four tests covering the plan all
// passed while the ACE was absent from disk. Something that runs the real
// binary and then stats the file is the only thing that catches that class.
//
// Deliberately NOT a debug curiosity: it takes the same path a shell tool
// takes, through SandboxManager.BuildCommandPlan, so what it proves is what
// users get. It prints the resolved backend and enforcement level to stderr
// before running, so a harness can assert the sandbox was actually engaged
// rather than quietly downgraded.
func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
command, err := parseSandboxExecArgs(args)
if err != nil {
if errors.Is(err, errSandboxExecHelp) {
if writeErr := writeSandboxExecHelp(stdout); writeErr != nil {
return exitCrash
}
return exitSuccess
}
return writeExecUsageError(stderr, err.Error())
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash)
}
// Through the ENGINE, not a SandboxManager built here.
//
// The engine is what a real tool call goes through, and it does more than
// hand the manager a request: it resolves the permission profile, calls
// prepareSandboxRuntime, and folds the runtime state into that profile
// before planning. Building a manager directly skipped all of it, so the
// command ran without the runtime write root, and cache or temp writes could
// pass or fail differently from the sandboxed command this exists to imitate.
// A harness that exercises the wrong path is worse than no harness, because
// its result still reads as evidence.
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: policy,
Scope: scope,
Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}),
// The same scrub list a real tool call gets. Without it the engine keeps an
// empty set, and only the hardcoded names plus the provider catalog's own
// AuthEnvVars are removed from the child's environment: a key named by
// `apiKeyEnv` in the user's config would be scrubbed for every sandboxed
// tool call and handed to this one. Reproducing the production environment
// is the entire point of the command, and a credential is the last part of
// it that may differ.
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{
Name: command[0],
Args: command[1:],
Dir: workspaceRoot,
Env: os.Environ(),
})
if err != nil {
return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash)
}
// The plan owns resources beyond its construction: on Linux it allocates a
// policy-report file and registers the removal here, so without this every
// invocation leaves a /tmp/zero-sandbox-report-* behind.
defer plan.Cleanup()
// Printed before the command runs and on stderr, so it survives a command
// that writes to stdout and stays greppable by a test harness. A downgrade
// is reported loudly for the same reason: a smoke test that passes because
// the sandbox quietly stood down is worse than no smoke test.
fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n",
plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot)
if strings.TrimSpace(plan.DowngradeReason) != "" {
fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason)
}
return runSandboxPlannedCommand(plan, stdout, stderr)
}
func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int {
process := exec.Command(plan.Name, plan.Args...)
process.Dir = plan.Dir
if process.Dir == "" {
process.Dir = plan.WorkspaceRoot
}
if len(plan.Env) > 0 {
process.Env = plan.Env
}
process.Stdin = os.Stdin
process.Stdout = stdout
process.Stderr = stderr
if err := process.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// The command's own status, not ours. A harness asserting "the write
// was refused" needs the refusal's exit code, not a wrapper's.
// A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1
// there, and os.Exit truncates that to 255, so a child that took SIGTERM
// became indistinguishable from one that chose to exit 255. Fold the
// signal into the conventional 128+n a shell would report, which needs
// the ProcessState rather than the integer.
if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
return status
}
return exitErr.ExitCode()
}
fmt.Fprintf(stderr, "sandbox exec: %v\n", err)
return exitCrash
}
return exitSuccess
}
var errSandboxExecHelp = errors.New("help requested")
// parseSandboxExecArgs takes everything after `--` as the command, so the
// command's own flags are never mistaken for ours.
func parseSandboxExecArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
}
// Only the FIRST token is ours to interpret. Everything from the second
// onwards belongs to the child, help flags included.
//
// Written as a straight-line decision on args[0] rather than a loop, because
// a loop here is a lie. An earlier version scanned for `--` "up front" and
// said so in its comment, but every branch of its body returned, so it only
// ever examined index 0 and the promised scan did not exist. The rule below
// is what that code actually implemented, and it is the rule we want: it is
// the wrapper's own prefix that can ask for the wrapper's help, and the
// prefix is at most one token long.
switch args[0] {
case "--":
// Everything after the separator is the command, verbatim, including
// `--help`. That is the documented contract: `zero sandbox exec -- cmd
// --help` must run cmd's help, not ours.
command := args[1:]
if len(command) == 0 {
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
}
return command, nil
case "-h", "--help", "help":
return nil, errSandboxExecHelp
}
// The separator-less form, tolerated for interactive use: the first token is
// the command, so nothing after it is ours to read. Without this,
// `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd,
// which is the same contract break as reading past `--`. The help text still
// shows the separator, because anything with a leading dash needs it.
return args, nil
}
func writeSandboxExecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero sandbox exec -- <command> [args...]
Runs one command through the real sandbox and exits with its status.
Everything after the -- separator is the command, so its own flags are not
parsed as Zero's. The resolved backend and enforcement level are written to
stderr before the command runs, and a downgrade is reported there explicitly.
Examples:
zero sandbox exec -- cmd /c echo hello
zero sandbox exec -- powershell -Command "Set-Content out.txt x"
`)
return err
}
-28
View File
@@ -1,28 +0,0 @@
//go:build !windows
package cli
import (
"os"
"syscall"
)
// signaledExitStatus reports the conventional shell status for a child that was
// terminated by a signal, which exec.ExitError cannot represent.
//
// ExitCode() returns -1 for a signaled child, and the top level hands that to
// os.Exit, which truncates it to 255. A child that takes SIGTERM is then
// indistinguishable from one that exited 255 of its own accord, which breaks the
// documented status contract for a command whose whole job is to report the
// child's own status faithfully. 128+signal is what a shell reports and what a
// harness comparing statuses expects.
func signaledExitStatus(state *os.ProcessState) (int, bool) {
if state == nil {
return 0, false
}
status, ok := state.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() {
return 0, false
}
return 128 + int(status.Signal()), true
}
@@ -1,65 +0,0 @@
//go:build !windows
package cli
import (
"errors"
"os/exec"
"syscall"
"testing"
)
// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255.
//
// `sandbox exec` exists to report the child's own status faithfully, and
// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and
// the top level hands that to os.Exit, which truncates it to 255. So a child
// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a
// harness comparing statuses could not tell a refusal from a kill.
//
// Driven with a real subprocess and a real signal rather than a synthetic
// WaitStatus, because the mapping is only correct if the OS agrees with it.
func TestSignaledChildReportsTheConventionalStatus(t *testing.T) {
for _, testCase := range []struct {
name string
script string
want int
}{
{name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)},
{name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)},
} {
t.Run(testCase.name, func(t *testing.T) {
err := exec.Command("/bin/sh", "-c", testCase.script).Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
}
// The behaviour being corrected: the integer alone cannot say this.
if code := exitErr.ExitCode(); code != -1 {
t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code)
}
status, signaled := signaledExitStatus(exitErr.ProcessState)
if !signaled {
t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255")
}
if status != testCase.want {
t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want)
}
})
}
}
// An ordinary non-zero exit is untouched: it has a real code and must keep it.
func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) {
err := exec.Command("/bin/sh", "-c", "exit 3").Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
}
if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status")
}
if code := exitErr.ExitCode(); code != 3 {
t.Fatalf("exit code = %d, want the child's own 3", code)
}
}
@@ -1,12 +0,0 @@
//go:build windows
package cli
import "os"
// signaledExitStatus has no Windows counterpart: a process there ends with an
// exit code, and there is no signal to fold into one. Windows keeps the exit
// code exec.ExitError already reports.
func signaledExitStatus(*os.ProcessState) (int, bool) {
return 0, false
}
-108
View File
@@ -1,108 +0,0 @@
package cli
import (
"errors"
"strings"
"testing"
)
// parseSandboxExecArgs sits on the boundary between Zero's flags and a child
// command's, and it had no tests at all: not for the reported defect, not even
// for the happy case. The contract it has to keep is that everything after the
// separator belongs to the child, help flags included, so the wrapper can never
// answer for a command it was supposed to be running.
func TestParseSandboxExecArgs(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
command []string
help bool
usage bool
}{
{
// The reported defect: --help after the separator is the CHILD's.
name: "help after the separator belongs to the child",
args: []string{"--", "cmd", "--help"},
command: []string{"cmd", "--help"},
},
{
name: "a second separator is the child's too",
args: []string{"--", "cmd", "--", "inner"},
command: []string{"cmd", "--", "inner"},
},
{
name: "a flag-looking command survives the separator",
args: []string{"--", "--weird-binary"},
command: []string{"--weird-binary"},
},
{
// The separator-less form. The first token is the command, so its own
// flags are not ours to read either.
name: "help after a separatorless command belongs to the child",
args: []string{"cmd", "--help"},
command: []string{"cmd", "--help"},
},
{
// A separator arriving AFTER the command has already started is part of
// the child's argv, not a delimiter we get a second go at.
name: "a separator after the command is the child's argument",
args: []string{"ls", "--", "foo"},
command: []string{"ls", "--", "foo"},
},
{name: "bare help", args: []string{"--help"}, help: true},
{name: "short help", args: []string{"-h"}, help: true},
{name: "help subcommand", args: []string{"help"}, help: true},
{name: "help in the prefix wins", args: []string{"--help", "--", "cmd"}, help: true},
{name: "no arguments", args: nil, usage: true},
{name: "separator with nothing after it", args: []string{"--"}, usage: true},
} {
t.Run(testCase.name, func(t *testing.T) {
command, err := parseSandboxExecArgs(testCase.args)
switch {
case testCase.help:
if !errors.Is(err, errSandboxExecHelp) {
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want the help sentinel", testCase.args, command, err)
}
case testCase.usage:
if err == nil || errors.Is(err, errSandboxExecHelp) {
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want a usage error", testCase.args, command, err)
}
if !strings.Contains(err.Error(), "usage:") {
t.Errorf("a usage error must show the usage, got %q", err.Error())
}
default:
if err != nil {
t.Fatalf("parseSandboxExecArgs(%q) returned %v", testCase.args, err)
}
if strings.Join(command, "\x00") != strings.Join(testCase.command, "\x00") {
t.Errorf("parseSandboxExecArgs(%q) = %q, want %q", testCase.args, command, testCase.command)
}
}
})
}
}
// The wrapper must never consume a token it then fails to hand over. Whatever it
// returns as the command has to be a trailing slice of what it was given, with
// at most a leading separator removed, or an argument has gone missing on its
// way to the child.
func TestParseSandboxExecArgsNeverDropsAChildArgument(t *testing.T) {
for _, args := range [][]string{
{"--", "cmd", "--help", "-x", "--"},
{"cmd", "--help"},
{"ls", "--", "foo"},
{"--", "--weird-binary", "arg"},
} {
command, err := parseSandboxExecArgs(args)
if err != nil {
t.Fatalf("parseSandboxExecArgs(%q) returned %v", args, err)
}
if len(command) == 0 {
t.Fatalf("parseSandboxExecArgs(%q) returned an empty command", args)
}
suffix := strings.Join(args[len(args)-len(command):], "\x00")
if strings.Join(command, "\x00") != suffix {
t.Errorf("parseSandboxExecArgs(%q) = %q, which is not a trailing slice of the input; an argument was dropped or reordered", args, command)
}
}
}
-17
View File
@@ -1,17 +0,0 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
-6
View File
@@ -119,12 +119,6 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
+66 -9
View File
@@ -5,23 +5,31 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning. The lock is held only for a single metadata
// read-modify-write (milliseconds), never across a job's exec, so the timeout
// is generous relative to any real hold.
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
// generous and the stale threshold sits far above any real hold.
const (
cronLockTimeout = 10 * time.Second
cronLockStaleAfter = 60 * time.Second
cronLockRetryDelay = 20 * time.Millisecond
)
// lockJob takes a cross-process advisory lock for one job on a stable sibling
// "<id>.lock" file next to the job directory. It serializes the read-modify-write
// of a job's metadata across concurrent schedulers and commands. The kernel
// releases the lock when a holder exits; the file is never renamed or removed.
var cronLockSeq atomic.Uint64
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
// the job dir never deletes a live lock). It serializes the read-modify-write of
// a job's metadata across concurrent schedulers and commands. The lock file is
// removed on release; a stale lock from a crashed holder (older than
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
// (not the injectable Store.now) so lock timing never depends on a frozen test
// clock and the stale check compares against real file mtimes.
func (s *Store) lockJob(id string) (func(), error) {
if !validID(id) {
return nil, fmt.Errorf("invalid cron job id %q", id)
@@ -30,15 +38,64 @@ func (s *Store) lockJob(id string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
deadline := time.Now().Add(cronLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: write job lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename of a given file
// wins) and restores it if it turns out fresh, so a live lock is never deleted
// out from under its holder.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
}
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
}
+1 -1
View File
@@ -64,7 +64,7 @@ func TestMutateRemovedJobReturnsNotFound(t *testing.T) {
if !errors.Is(err, ErrJobNotFound) {
t.Fatalf("Mutate on removed job = %v, want ErrJobNotFound", err)
}
// The stable advisory-lock file does not resurrect the removed job dir.
// And the lock file left no litter / did not resurrect the job dir.
if _, gerr := store.Get(job.ID); !errors.Is(gerr, ErrJobNotFound) {
t.Fatalf("job should remain removed, Get = %v", gerr)
}
+67 -35
View File
@@ -3,60 +3,107 @@ package daemon
import (
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Single-instance lock. The stable PID file carries a kernel advisory lock for
// the daemon's full lifetime. Process exit releases the lock automatically, so
// stale metadata needs no rename/remove recovery protocol.
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
// daemon recovers from an unclean shutdown without manual cleanup.
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
// fileLock is an acquired single-instance lock.
type fileLock struct {
lock *lockutil.FileLock
path string
}
// processAlive reports whether pid is a live process. Implemented per-platform
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
var processAlive = osProcessAlive
// acquireLock takes the single-instance advisory lock at path. isAlive is used
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
// stub); nil uses the real processAlive.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
lock, err := lockutil.TryAcquireFileLock(path)
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
// At most two passes: create, or detect-stale-then-retry once.
for attempt := 0; attempt < 2; attempt++ {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
// A failed PID write would leave a malformed lock file that another
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
// the single-instance guarantee — so on write failure, remove it and fail.
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(path)
return nil, werr
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(path)
return nil, cerr
}
return &fileLock{path: path}, nil
}
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, err
}
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
return nil, ErrAlreadyRunning
// Stale lock (dead PID or unreadable) — reclaim it atomically, then retry the
// O_EXCL create. A blind Remove here races: two daemons starting at once could
// both read the stale PID, both Remove, and then one Removes the OTHER's
// freshly-created lock — leaving both "holding" the single-instance lock.
// reclaimStaleLock renames the file aside so only one racer wins the rename,
// and restores it if a live holder reacquired in the gap (D6).
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break the single-instance guarantee).
// Fail closed instead of spinning to the deadline.
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
}
}
if err := lock.WriteMetadata([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil {
return nil, errors.Join(err, lock.Release())
}
return &fileLock{lock: lock}, nil
return nil, ErrAlreadyRunning
}
// release drops the kernel lock and closes its handle. The stable PID file is
// deliberately retained so contenders always address the same inode.
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
var daemonLockSeq atomic.Uint64
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
// (only one racer can win the rename) and stolen only if the moved file's PID
// is still dead; a holder that reacquired the lock in the gap between the
// stale check and the rename carries a LIVE pid and is restored rather than
// stolen. Returns true only when a genuinely stale lock was removed. A
// non-nil error means the caller must fail closed instead of re-acquiring
// (see lockutil.ReclaimStaleLock).
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
pid, err := readPidFile(reclaimedPath)
return err == nil && pid > 0 && isAlive(pid)
})
}
// release removes the lock file. Safe to call once. An already-missing lock
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
func (l *fileLock) release() error {
if l == nil || l.lock == nil {
if l == nil || l.path == "" {
return nil
}
return l.lock.Release()
return lockutil.RemoveLockFile(l.path)
}
// readPidFile reads and parses the PID recorded in a lock file.
@@ -65,20 +112,5 @@ func readPidFile(path string) (int, error) {
if err != nil {
return 0, err
}
metadata := strings.TrimSpace(string(data))
if pid, err := strconv.Atoi(metadata); err == nil {
return pid, nil
}
pidText, sequenceText, ok := strings.Cut(metadata, "-")
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
}
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
}
pid, err := strconv.Atoi(pidText)
if err != nil {
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
}
return pid, nil
return strconv.Atoi(strings.TrimSpace(string(data)))
}
+42 -54
View File
@@ -32,48 +32,27 @@ func TestLockSingleInstance(t *testing.T) {
_ = l2.release()
}
// A PID liveness probe is diagnostic only. Even if it reports the recorded PID
// dead, a contended kernel lock proves that the holder is active and must win.
// The old rename-aside protocol trusted this stale observation and admitted a
// second daemon while the first one was still inside its critical section.
func TestLockDoesNotOverrideKernelHolderWithStalePIDProbe(t *testing.T) {
func TestLockStaleRecovery(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
first, err := acquireLock(path, func(int) bool { return true })
if err != nil {
t.Fatalf("first acquireLock: %v", err)
}
defer first.release()
second, err := acquireLock(path, func(int) bool { return false })
if second != nil {
_ = second.release()
}
if !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("contended acquire with stale PID probe = %v, want ErrAlreadyRunning", err)
}
}
func TestLockIgnoresStaleMetadataWhenKernelLockIsFree(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
// A crashed daemon may leave PID metadata behind, but its kernel lock is
// released automatically, so the next daemon can acquire without moving it.
// Simulate a stale lock from a crashed daemon: a PID file whose process is
// dead.
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
dead := func(int) bool { return false }
l, err := acquireLock(path, dead)
if err != nil {
t.Fatalf("acquire over stale metadata: %v", err)
t.Fatalf("stale-lock recovery failed: %v", err)
}
// The lock now records OUR pid, not the stale one.
data, _ := os.ReadFile(path)
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
t.Fatalf("lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
}
_ = l.release()
}
func TestLockReleaseKeepsStableFile(t *testing.T) {
func TestLockReleaseRemovesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
l, err := acquireLock(path, func(int) bool { return true })
if err != nil {
@@ -82,37 +61,46 @@ func TestLockReleaseKeepsStableFile(t *testing.T) {
if err := l.release(); err != nil {
t.Fatalf("release: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("release removed stable lock file: %v", err)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("lock file still present after release: %v", err)
}
}
func TestReadPidFileMetadataFormats(t *testing.T) {
func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
for _, test := range []struct {
name string
metadata string
wantPID int
wantErr bool
}{
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
{name: "missing sequence", metadata: "4242-", wantErr: true},
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
{name: "extra component", metadata: "4242-7-8", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
t.Fatal(err)
}
got, err := readPidFile(path)
if (err != nil) != test.wantErr {
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
}
if !test.wantErr && got != test.wantPID {
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
}
})
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
}
}
func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
// If a holder reacquires the lock in the gap between the stale check and the
// rename, the moved file carries a LIVE pid; reclaim must restore it untouched
// rather than steal it — otherwise two daemons both "hold" the lock (D6).
path := filepath.Join(t.TempDir(), "daemon.lock")
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live lock must be restored in place, read = %v", err)
}
if strings.TrimSpace(string(data)) != "4242" {
t.Fatalf("restored lock content = %q, want %q (unchanged)", strings.TrimSpace(string(data)), "4242")
}
// No sidelined ".stale" leftovers.
matches, _ := filepath.Glob(path + ".stale.*")
if len(matches) != 0 {
t.Fatalf("reclaim left sidelined files: %v", matches)
}
}
+2 -6
View File
@@ -124,16 +124,12 @@ func TestServerEndToEnd(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
// Runtime endpoints are removed on exit. The advisory lock file remains as
// a stable inode, but the kernel lock on it has been released.
for _, p := range []string{paths.Socket, paths.Status} {
// Socket, status, and lock files are removed on exit.
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
}
}
if _, err := os.Stat(paths.Lock); err != nil {
t.Fatalf("stable lock file missing after shutdown: %v", err)
}
}
func TestServerSecondInstanceFails(t *testing.T) {
+4 -58
View File
@@ -100,17 +100,10 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
}
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
setupConfig := sandbox.WindowsSandboxSetupConfig{
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
// Same augmentation the setup args and the command plan apply, so doctor
// fingerprints what a real command fingerprints. Safe here: doctor runs in
// the operator's shell, not behind the sandbox's TEMP redirection.
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
// Same opt-in a command would resolve, so doctor reports the principal
// mismatch as out-of-date setup instead of passing a check the next command
// will fail.
PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil),
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
}
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
@@ -122,53 +115,6 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
})
return &result
}
// Setup is valid and the opt-in is set, so report which principal a command
// will actually run as.
//
// This used to warn that the principal stood down under the DEFAULT
// network-deny policy, which was true while network denial could only be
// expressed on the restricted token: a principal token cannot carry the
// synthetic offline-marker SID the WFP filters matched. Dual-role provisioning
// removed that limit by giving each workspace an offline account whose real
// group membership the same filters match, so deny mode now runs on a
// principal exactly as allow mode does. Leaving the warning would have doctor
// telling operators their reads are unconfined while they are confined.
//
// Naming the role rather than just saying "active" is the useful part. The two
// accounts differ only in that membership, so an operator debugging network
// behaviour needs to know which one this workspace picks.
if setupConfig.PrincipalOptIn {
// Asked of the shared rule rather than re-derived here, so doctor and the
// runtime cannot disagree about which account a command runs as. Two
// copies drifting is what produced the wrong report this replaces.
role := sandbox.WindowsSandboxPrincipalRoleForNetwork(profile.Network.Mode)
// SETUP IS CURRENT IS NOT THE SAME AS THE PRINCIPAL IS LIVE, and this used
// to claim the second from the first. Marker validation compares serialized
// plans and hashes; it names no account. Delete the selected account or its
// secret, or drop the offline account out of ZeroSandboxOffline, and the
// marker still validates while the runtime either falls back to the weaker
// restricted-token path or fails outright. Doctor asserting read
// confinement in that state is worse than saying nothing, because it is the
// surface an operator checks precisely when they are unsure.
//
// Verifying liveness needs the account, a usable secret and (for offline)
// the group membership, all Windows-only live queries this package cannot
// make. So the claim is narrowed to what the marker actually proves, and
// the role is still named because that part IS derived rather than assumed
// and is what an operator debugging network behaviour needs.
result := check("sandbox.principal", "Sandbox principal", StatusPass,
fmt.Sprintf("Sandbox principal setup is current; a command in this workspace selects the %s principal. Setup state does not confirm the account is usable.", role), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"optIn": true,
// Deliberately not "active": nothing here checked the account, the
// secret or the group membership.
"setupCurrent": true,
"role": role,
"networkMode": string(profile.Network.Mode),
})
return &result
}
return nil
}
@@ -1,72 +0,0 @@
package doctor
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// A VALID MARKER IS NOT A LIVE PRINCIPAL. Marker validation compares serialized
// plans and hashes and names no account, so it stays valid after the selected
// account is deleted, its secret is removed, or the offline account is dropped
// out of ZeroSandboxOffline. In each of those states the runtime falls back to
// the restricted-token path with no read confinement, or fails outright.
//
// Doctor is the surface an operator checks precisely when they are unsure, so
// claiming read confinement it has not verified is worse than reporting less.
// This pins the narrower claim: setup is current, the role is named because it
// is derived rather than assumed, and nothing asserts the account is usable.
func TestPrincipalCheckDoesNotClaimAnUnverifiedPrincipalIsActive(t *testing.T) {
sandboxHome := t.TempDir()
workspaceRoot := t.TempDir()
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", sandboxHome)
t.Setenv("ZERO_WINDOWS_SANDBOX_IDENTITY", "1")
sandboxConfig := config.SandboxConfig{}
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
// Mirrors what windowsSandboxSetupCheck derives, so the marker it writes is
// the one the check will validate against. Nothing here provisions an
// account, which is the whole point: the marker is valid and the principal
// does not exist.
if _, err := sandbox.WriteWindowsSandboxSetupMarker(sandbox.WindowsSandboxSetupConfig{
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
PrincipalOptIn: true,
}); err != nil {
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
}
result := windowsSandboxSetupCheck("windows", sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}, workspaceRoot, sandboxConfig)
if result == nil {
t.Fatal("expected a principal check once the marker validates and the opt-in is set")
}
if result.ID != "sandbox.principal" {
t.Fatalf("check = %q (%s): the marker did not validate, so this test is not exercising the principal branch", result.ID, result.Message)
}
lowered := strings.ToLower(result.Message)
for _, claim := range []string{"is active", "commands run as"} {
if strings.Contains(lowered, claim) {
t.Errorf("doctor asserts %q about a principal it never checked: %q", claim, result.Message)
}
}
if active, ok := result.Details["active"]; ok {
t.Errorf("details carry active=%v, which nothing here verified", active)
}
// The role is still worth reporting: it is derived from the network mode by
// the shared rule rather than assumed, and it is what an operator debugging
// network behaviour needs.
if role, ok := result.Details["role"]; !ok || strings.TrimSpace(role.(string)) == "" {
t.Errorf("the selected role should still be reported: %#v", result.Details)
}
}
+29 -157
View File
@@ -3,93 +3,13 @@
package execution
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// requireProcessState refuses to let a broken ps pipeline masquerade as passing
// coverage. Every test in this file proves something about zombie detection,
// and every one of those proofs is read out of ps — so if ps cannot report
// state, the tests verify nothing whatever they do next.
//
// The old code answered that with t.Skip, which is indistinguishable from
// success in CI output. This probes BOTH ps pipelines against this test
// process, which is by definition alive and not a zombie, so a working ps must
// report a live state for it. Failing that:
//
// - In CI (or with ZERO_REQUIRE_PS=1) it is a hard failure. ps is present on
// every image used today, so its absence means the pipeline regressed and
// that has to surface immediately rather than as skipped-green.
// - Elsewhere it stays a skip, so a contributor on an exotic box is not
// blocked by an environment the project does not claim to support.
//
// This only covers "ps cannot report state at all". Once it passes, a later
// failure to observe an EXPECTED state is a real defect, and the tests below
// treat it as one.
func requireProcessState(t *testing.T) {
t.Helper()
self := os.Getpid()
var reasons []string
// The production path: ps -A -o pid=,pgid=,stat=, parsed by
// processTableStateMatches. This is what signalTargetRunning consults.
if running, ok := processIsRunning(self); !ok || !running {
reasons = append(reasons, "the process table read used by signalTargetRunning reported no live state for this test process")
}
// This file's independent oracle: ps -o stat= -p <pid>. It is deliberately
// NOT the production parser, so a regression in that parser cannot hide by
// breaking the test's own precondition check in the same way.
if zombie, ok := processIsZombie(self); !ok || zombie {
reasons = append(reasons, "the per-PID state read used by this test file reported no live state for this test process")
}
if len(reasons) == 0 {
return
}
reason := "cannot read process state, so ps-based zombie detection is unverifiable here: " + strings.Join(reasons, "; ")
if requireWorkingPS() {
t.Fatal(reason + " — failing rather than skipping because CI (or ZERO_REQUIRE_PS) is set, where ps is expected to work")
}
t.Skip(reason)
}
// requireWorkingPS reports whether an unusable ps must fail the run instead of
// skipping it. ZERO_REQUIRE_PS lets a developer opt into the strict behavior
// locally to reproduce what CI does.
func requireWorkingPS() bool {
if strings.TrimSpace(os.Getenv("ZERO_REQUIRE_PS")) != "" {
return true
}
return strings.TrimSpace(os.Getenv("CI")) != ""
}
// awaitZombie blocks until pid is an unreaped zombie. The caller must not have
// waited on the child, so the zombie is a stable state rather than a race: it
// persists until something reaps it. Failing to reach it therefore means the
// child never exited or ps stopped reporting its state, and both are defects
// rather than reasons to skip.
func awaitZombie(t *testing.T, pid int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
zombie, ok := processIsZombie(pid)
if ok && zombie {
return
}
if time.Now().After(deadline) {
if !ok {
t.Fatalf("pid %d left the process table without being reaped; this test never waited on it, so its zombie row must persist", pid)
}
t.Fatalf("pid %d did not reach the zombie state within 5s", pid)
}
time.Sleep(10 * time.Millisecond)
}
}
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
@@ -97,8 +17,6 @@ func awaitZombie(t *testing.T, pid int) {
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -109,59 +27,23 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
awaitZombie(t, pid)
// Whether a zombie-only group can be signalled is a KERNEL difference, not a
// ps one. On Linux the zombie still occupies the process table and
// kill(-pid, 0) succeeds, so signalTargetRunning goes on to consult ps —
// which is the branch this test was written for. Darwin returns EPERM for
// the same call, so signalTargetRunning short-circuits on the kill and never
// reaches the ps check.
//
// The OUTCOME is identical and is asserted on every platform below. Only the
// branch that produces it differs, so record that instead of skipping the
// whole test (which would drop Darwin's coverage of the outcome) or failing
// it (which would report a supported platform as broken). Any errno other
// than EPERM means the group is not in the state this test needs.
psPathReachable := true
if err := syscall.Kill(-pid, syscall.Signal(0)); err != nil {
if !errors.Is(err, syscall.EPERM) {
t.Fatalf("zombie group %d is unsignalable with an unexpected error (%v); the zombie case is not present, so nothing below would be meaningful", pid, err)
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
psPathReachable = false
t.Logf("kernel refuses to signal zombie-only group %d (EPERM); signalTargetRunning answers from kill(2) rather than ps here, so the ps-based zombie check is asserted directly on platforms that allow the signal", pid)
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
}
// Where the signal IS permitted, pin the ps-based check itself. This is the
// regression #774 was about, and asserting only signalTargetRunning would
// let a ps-side regression hide behind the kill result.
if psPathReachable {
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows while its zombie is unreaped; ps stopped reporting the group rather than the group being gone", pid)
}
if running {
t.Fatal("the ps-based group check must not report a zombie-only group as running")
}
}
// Valid on every platform: whichever branch answers, a zombie-only group is
// not running. On a kernel that refuses the signal this is the whole of the
// coverage, and it is more than the previous skip provided.
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if !psPathReachable {
// terminateTarget's own kill(2) would hit the same EPERM and be returned
// as a cleanup error, so this assertion cannot be made where the kernel
// refuses the signal. That may be a real gap in TerminateProcessTree on
// such a platform rather than a test limitation — a zombie-only tree is
// already exited and should terminate cleanly — but confirming and
// changing that is a production fix, not this test's business.
t.Log("skipping the TerminateProcessTree assertion: its kill(2) would hit the same EPERM as the probe above")
return
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
@@ -171,8 +53,6 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -187,15 +67,7 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
// ok reports whether ANY row for this group was found. This test never waits
// on the child, so its zombie row must still be there — an unknown state
// means ps stopped answering, not that the group is gone. Treating that as a
// pass is what let this assertion succeed without checking anything.
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows after termination; the unreaped child's zombie row must persist, so the group's state is undeterminable rather than clean", pid)
}
if running {
if running, ok := processGroupHasRunningMember(pid); ok && running {
t.Fatal("the group still has a running member after termination")
}
}
@@ -210,8 +82,6 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
@@ -222,38 +92,40 @@ func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
awaitZombie(t, pid)
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
// The child inherited this process's group, so its PGID is this process's
// PID and can never equal its own PID. A negative target here would mean
// processSignalTarget misread the group, which is the very thing the
// individual-PID fallback exists to handle.
if target != pid {
t.Fatalf("processSignalTarget(%d) = %d, want the individual PID: the child inherited this process's group, so it is not its own leader", pid, target)
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. It is deliberately a
// separate, per-PID ps invocation rather than a reuse of the production
// process-table parser: these tests assert what that parser concludes, so
// borrowing it to establish their own preconditions would let one regression
// silence both sides at once.
//
// ok is false when no state could be read, which after requireProcessState has
// passed means the process left the table rather than that ps is broken.
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := strings.TrimLeft(string(output), " \n\t")
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
if state == "" {
return false, false
}
+63 -9
View File
@@ -5,39 +5,93 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning for the audit log. The lock is held only across a
// single read-then-append (milliseconds), so the timeout is generous relative
// to any real hold.
// single read-then-append (milliseconds), so the timeout is generous and the
// stale threshold sits far above any real hold.
const (
auditLockTimeout = 10 * time.Second
auditLockStaleAfter = 60 * time.Second
auditLockRetryDelay = 20 * time.Millisecond
)
// lockAudit takes a cross-process advisory lock on the audit log through a
// stable sibling "<auditPath>.lock" file. It makes
var auditLockSeq atomic.Uint64
// lockAudit takes a cross-process exclusive lock on the audit log by
// O_EXCL-creating a sibling "<auditPath>.lock" file (removed on release). It makes
// the lastSequence()+append in append() atomic across processes; the audit log is
// a shared global file, so without it two processes can read the same last
// sequence and write duplicate numbers. Kernel lock lifetime provides crash
// recovery without moving or deleting the lock path.
// sequence and write duplicate numbers. A stale lock from a crashed holder (older
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
// lock timing never depends on the store's injectable clock and the stale check
// compares against real file mtimes. This mirrors internal/cron/lock.go and
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
func (store *AuditStore) lockAudit() (func(), error) {
lockPath := store.auditPath + ".lock"
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), auditLockSeq.Add(1))
deadline := time.Now().Add(auditLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat both
// as contention and retry.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("hooks: acquire audit lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("hooks: timed out acquiring audit lock")
}
+48 -111
View File
@@ -1,127 +1,64 @@
// Package lockutil provides cross-process advisory file locks used by cron,
// daemon, hooks, oauth, and swarm. Locks are held by the kernel for the lifetime
// of an open file handle, so process exit releases them without a stale-file
// reclaim protocol. The lock file itself is stable: holders never rename or
// remove it while another process may still have its inode locked.
// Package lockutil provides the platform-specific file primitives behind the
// O_EXCL lock files in cron, daemon, hooks, oauth, and swarm: a no-overwrite
// restore for locks that were sidelined during a stale-reclaim attempt, and a
// lock file remover with one cross-platform contract (missing files are a
// no-op; Windows retries transient sharing violations).
package lockutil
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
var (
// ErrLockHeld reports that another process or goroutine currently holds the
// advisory lock. Callers may wait and retry until their own deadline.
ErrLockHeld = errors.New("lockutil: lock is held")
// ErrLockReleased reports an attempt to update metadata after Release.
ErrLockReleased = errors.New("lockutil: lock is released")
)
var holderSequence atomic.Uint64
// FileLock is an exclusive advisory lock held through an open file handle.
// Release is idempotent. The lock path remains on disk after release; deleting
// or replacing it would let different processes lock different inodes.
type FileLock struct {
mu sync.Mutex
file *os.File
state platformLockState
released bool
releaseErr error
}
// TryAcquireFileLock attempts to take an exclusive advisory lock without
// waiting. A contended lock returns ErrLockHeld. The kernel releases a lock if
// its process exits, so an old unlocked file never needs stale reclamation.
func TryAcquireFileLock(path string) (*FileLock, error) {
return TryAcquireFileLockAt(filepath.Dir(path), path)
}
// TryAcquireFileLockAt attempts to lock path while confining every component
// below root to a handle-relative traversal. root is the caller's trusted
// boundary and may itself be reached through a legitimate link; links and
// reparse points below it are rejected before metadata can be written.
func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
if root == "" {
return nil, errors.New("lockutil: lock root is empty")
}
relative, err := filepath.Rel(root, path)
// restoreByCopy restores reclaimed to path without overwriting an existing
// path, as a fallback for when the platform's primary no-replace primitive
// (hard link on POSIX, MoveFileEx on Windows) fails for a reason other than
// the destination existing. It stages a full copy under a private name next
// to reclaimed and publishes it to path with publish (the same no-replace
// primitive the caller's platform uses for the primary restore), so path
// never appears in a partially-copied state: a crash between staging and
// publish leaves path exactly as it was (missing or, if a competing holder
// created it, untouched), never a truncated file that a PID/content-based
// liveness check could mistake for dead. Leaving path missing here would let
// the next O_EXCL create succeed while the sidelined holder is still live,
// breaking mutual exclusion. publish keeps the no-overwrite guarantee: a new
// holder that appeared in the meantime wins and this returns os.ErrExist. The
// copy resets the lock's mtime to now, which only makes the restored lock
// look fresher; that is safe, since it is being handed back to a live holder.
func restoreByCopy(reclaimed, path string, publish func(from, to string) error) error {
staged := reclaimed + ".copy"
src, err := os.Open(reclaimed)
if err != nil {
return nil, fmt.Errorf("lockutil: resolve lock path relative to root: %w", err)
return err
}
relative = filepath.Clean(relative)
if relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("lockutil: lock path %q escapes root %q", path, root)
}
file, err := openLockFileAt(root, relative, path)
dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
_ = src.Close()
return err
}
state, contended, err := tryLockFile(file)
_, err = io.Copy(dst, src)
// Close the source before removing anything below: Go opens files without
// FILE_SHARE_DELETE on Windows, so deleting reclaimed or staged while src
// is open would fail with a sharing violation.
_ = src.Close()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("lockutil: acquire advisory lock: %w", err)
_ = dst.Close()
_ = os.Remove(staged)
return err
}
if contended {
_ = file.Close()
return nil, ErrLockHeld
if err := dst.Close(); err != nil {
_ = os.Remove(staged)
return err
}
lock := &FileLock{file: file, state: state}
metadata := []byte(fmt.Sprintf("%d-%d\n", os.Getpid(), holderSequence.Add(1)))
if err := lock.WriteMetadata(metadata); err != nil {
return nil, errors.Join(err, lock.Release())
}
return lock, nil
}
// WriteMetadata replaces the diagnostic contents of the lock file while the
// caller holds it. Metadata does not establish ownership; the kernel lock does.
func (lock *FileLock) WriteMetadata(data []byte) error {
if lock == nil {
return ErrLockReleased
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released || lock.file == nil {
return ErrLockReleased
}
if err := lock.file.Truncate(0); err != nil {
return fmt.Errorf("lockutil: truncate lock metadata: %w", err)
}
if _, err := lock.file.Seek(0, 0); err != nil {
return fmt.Errorf("lockutil: seek lock metadata: %w", err)
}
if _, err := lock.file.Write(data); err != nil {
return fmt.Errorf("lockutil: write lock metadata: %w", err)
if err := publish(staged, path); err != nil {
_ = os.Remove(staged)
return err
}
// publish may leave staged behind (a hard-link publish keeps both names),
// so remove it explicitly; a move-based publish already made this a no-op.
_ = os.Remove(staged)
// The lock is back at path, so the restore has succeeded; failing to clean
// up the sidelined name must not be reported as a failed restore.
_ = RemoveLockFile(reclaimed)
return nil
}
// Release unlocks and closes the held file handle. It deliberately leaves the
// stable lock file in place. Repeated calls return the first release result.
func (lock *FileLock) Release() error {
if lock == nil {
return nil
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return lock.releaseErr
}
lock.released = true
unlockErr := unlockFile(lock.file, &lock.state)
closeErr := lock.file.Close()
lock.file = nil
if err := errors.Join(unlockErr, closeErr); err != nil {
lock.releaseErr = fmt.Errorf("lockutil: release advisory lock: %w", err)
}
return lock.releaseErr
}
+33 -82
View File
@@ -4,92 +4,43 @@ package lockutil
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
type platformLockState struct{}
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
parentFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
}
defer func() {
if parentFD >= 0 {
resultErr = errors.Join(resultErr, unix.Close(parentFD))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := unix.Close(parentFD); closeErr != nil {
_ = unix.Close(nextFD)
parentFD = -1
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parentFD = nextFD
}
name := components[len(components)-1]
fd := -1
// Darwin can transiently return ENOENT when many callers race to create the
// same O_NOFOLLOW entry. The bound parent handle cannot be redirected, so a
// bounded retry preserves first-use contention without weakening no-follow.
for attempt := 0; attempt < 10; attempt++ {
fd, err = unix.Openat(parentFD, name, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if !errors.Is(err, unix.ENOENT) {
break
}
}
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(fd), displayPath)
if file == nil {
_ = unix.Close(fd)
return nil, errors.New("convert lock handle to file")
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
return nil, errors.Join(fmt.Errorf("inspect lock file: %w", err), file.Close())
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, errors.Join(errors.New("refusing non-regular lock file"), file.Close())
}
if stat.Nlink != 1 {
return nil, errors.Join(errors.New("refusing multiply-linked lock file"), file.Close())
}
if err := unix.Close(parentFD); err != nil {
parentFD = -1
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parentFD = -1
return file, nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
// RestoreLockFile restores a sidelined lock file on non-Windows platforms. It
// uses os.Link so a competing lock created at path in the meantime wins: the
// link fails with os.ErrExist instead of overwriting it. If the link fails for
// any other reason (hardlink-incapable filesystems such as FAT or some
// FUSE/network mounts, ENOSPC, EPERM), it falls back to an O_EXCL copy rather
// than leaving path missing and the live holder's lock stranded in reclaimed.
// Once the lock is back at path the restore has succeeded, so a failed cleanup
// of the sidelined name is not reported as an error; the leftover file is
// invisible to the lock protocol.
func RestoreLockFile(reclaimed, path string) error {
err := os.Link(reclaimed, path)
if err == nil {
return platformLockState{}, false, nil
}
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, _ *platformLockState) error {
if file == nil {
_ = RemoveLockFile(reclaimed)
return nil
}
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
if errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, os.Link)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means it was lost to a racer rather than a hard failure. POSIX
// rename has no contention errno (a lost race surfaces only as ENOENT, which
// ReclaimStaleLock already treats as benign), so nothing extra is benign here.
func isReclaimContended(error) bool { return false }
// RemoveLockFile removes a lock file on non-Windows platforms. Removing an
// already-missing file reports nil, matching the Windows implementation, so
// callers see one cross-platform contract.
func RemoveLockFile(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
+101 -205
View File
@@ -1,220 +1,116 @@
package lockutil
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestFileLockSerializesAndKeepsStablePath(t *testing.T) {
func TestRestoreLockFile(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Successful restore when target does not exist
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := RestoreLockFile(reclaimed, path); err != nil {
t.Fatalf("RestoreLockFile failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !os.IsNotExist(err) {
t.Fatalf("expected sidelined lock to be cleaned up/removed: %v", err)
}
// 2. Fail when target already exists (prevent overwrite)
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := RestoreLockFile(reclaimed, path)
if err == nil {
t.Fatal("expected RestoreLockFile to fail when target exists")
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist, got: %v", err)
}
// The sidelined lock must still exist on failure, and the competing lock
// must be untouched.
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
}
func TestRestoreByCopy(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Restores content and cleans up the sidelined file when the target is
// free (the fallback taken when the platform's no-replace primitive fails
// for a reason other than the destination existing).
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); err != nil {
t.Fatalf("restoreByCopy failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected sidelined lock to be cleaned up after copy: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up: %v", err)
}
// 2. Keeps the no-overwrite guarantee: a competing lock at the target wins
// and the sidelined lock survives for the caller's ErrExist cleanup.
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist when target exists, got: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up after a failed publish: %v", err)
}
}
func TestRemoveLockFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
first, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("first acquire: %v", err)
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
firstInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat first lock path: %v", err)
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile failed: %v", err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
t.Fatalf("contended acquire = %v, want ErrLockHeld", err)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
if err := first.Release(); err != nil {
t.Fatalf("release first: %v", err)
}
releasedInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("release removed stable lock path: %v", err)
}
if !os.SameFile(firstInfo, releasedInfo) {
t.Fatal("release replaced the stable lock file")
}
second, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
secondInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat second lock path: %v", err)
}
if !os.SameFile(firstInfo, secondInfo) {
t.Fatal("reacquire used a different lock file")
}
if err := second.Release(); err != nil {
t.Fatalf("release second: %v", err)
}
if err := second.Release(); err != nil {
t.Fatalf("idempotent release: %v", err)
// Removing an already-missing lock file is a no-op on every platform.
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile on a missing file = %v, want nil", err)
}
}
func TestFileLockMetadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata([]byte("holder\n")); err != nil {
t.Fatalf("write metadata: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "holder\n" {
t.Fatalf("metadata = %q, err %v", data, err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata(nil); !errors.Is(err, ErrLockReleased) {
t.Fatalf("write after release = %v, want ErrLockReleased", err)
}
}
func TestFileLockConcurrentFirstAcquisition(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
const contenders = 200
start := make(chan struct{})
results := make(chan error, contenders)
locks := make(chan *FileLock, contenders)
var ready sync.WaitGroup
ready.Add(contenders)
for range contenders {
go func() {
ready.Done()
<-start
lock, err := TryAcquireFileLock(path)
if lock != nil {
locks <- lock
}
results <- err
}()
}
ready.Wait()
close(start)
acquired := 0
for range contenders {
err := <-results
switch {
case err == nil:
acquired++
case !errors.Is(err, ErrLockHeld):
t.Errorf("concurrent first acquire: %v", err)
}
}
close(locks)
for lock := range locks {
if err := lock.Release(); err != nil {
t.Error(err)
}
}
if acquired != 1 {
t.Fatalf("concurrent first acquire: got %d holders, want 1", acquired)
}
}
func TestFileLockRefusesMultiplyLinkedEntry(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
lockPath := filepath.Join(dir, "lock")
const sentinel = "do not overwrite"
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Link(target, lockPath); err != nil {
t.Fatalf("create redirected hard link: %v", err)
}
if lock, err := TryAcquireFileLock(lockPath); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLock accepted a multiply-linked entry")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != sentinel {
t.Fatalf("redirected target was modified: %q", data)
}
}
func TestFileLockAtRejectsEscapingPath(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(filepath.Dir(base), "outside.lock")
if lock, err := TryAcquireFileLockAt(base, outside); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLockAt accepted a path outside its root")
}
if _, err := os.Stat(outside); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("escaping path was created: %v", err)
}
}
// TestFileLockReleasedByProcessExit proves crash recovery is provided by the
// kernel lock lifetime. The child exits without calling Release; the next
// process can then lock the same persistent file without moving or deleting it.
func TestFileLockReleasedByProcessExit(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
cmd := exec.Command(os.Args[0], "-test.run=^TestFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_LOCKUTIL_HELPER=1", "ZERO_LOCKUTIL_PATH="+path)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("helper readiness = %q, err %v", ready, err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("acquire while helper holds lock = %v, want ErrLockHeld", err)
}
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after helper exit: %v", err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
}
func TestFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_LOCKUTIL_HELPER") != "1" {
return
}
lock, err := TryAcquireFileLock(os.Getenv("ZERO_LOCKUTIL_PATH"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
runtime.KeepAlive(lock) // deliberately exit without Release
}
+54 -179
View File
@@ -4,202 +4,77 @@ package lockutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
"syscall"
"time"
"golang.org/x/sys/windows"
)
type platformLockState struct {
overlapped windows.Overlapped
// RestoreLockFile restores a sidelined lock file on Windows. It moves the file
// with a no-replace MoveFileEx so a competing lock created at path in the
// meantime wins: the move fails with os.ErrExist instead of overwriting it. If
// the move fails for any other reason (ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED under concurrent access to the file), it falls back to
// an O_EXCL copy rather than leaving path missing and the live holder's lock
// stranded in reclaimed.
func RestoreLockFile(reclaimed, path string) error {
err := moveFileNoReplace(reclaimed, path)
if err == nil || errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, moveFileNoReplace)
}
const lockByteOffsetHigh = 1
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
rootPtr, err := windows.UTF16PtrFromString(root)
// moveFileNoReplace renames from to to, failing if to already exists. It calls
// MoveFileExW directly via golang.org/x/sys/windows (the standard library's
// syscall package does not export MoveFileEx on this platform) with no flags,
// so an existing destination fails with ERROR_ALREADY_EXISTS, which satisfies
// errors.Is(err, os.ErrExist) (pinned by
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist), instead of overwriting it
// the way os.Rename does on Windows (it passes MOVEFILE_REPLACE_EXISTING to
// match POSIX rename semantics cross-platform).
func moveFileNoReplace(from, to string) error {
fromPtr, err := windows.UTF16PtrFromString(from)
if err != nil {
return nil, err
return err
}
parent, err := windows.CreateFile(
rootPtr,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
toPtr, err := windows.UTF16PtrFromString(to)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
return err
}
defer func() {
if parent != 0 {
resultErr = errors.Join(resultErr, windows.CloseHandle(parent))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
next, openErr := openLockDirectoryAt(parent, component)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := windows.CloseHandle(parent); closeErr != nil {
_ = windows.CloseHandle(next)
parent = 0
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parent = next
}
handle, err := openLockFileHandleAt(parent, components[len(components)-1])
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), displayPath)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, errors.New("convert lock handle to file")
}
if err := windows.CloseHandle(parent); err != nil {
parent = 0
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parent = 0
return file, nil
return windows.MoveFileEx(fromPtr, toPtr, 0)
}
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
handle, err := openLockPathAt(
parent,
name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, true); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means the file was concurrently open or pending deletion (which
// surfaces as ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED on Windows)
// rather than a hard failure, so ReclaimStaleLock reports a lost race instead
// of an error. Neither errno satisfies errors.Is(err, os.ErrExist), and
// ERROR_SHARING_VIOLATION does not satisfy errors.Is(err, os.ErrPermission)
// either, so the classification compares raw errnos like RemoveLockFile below.
func isReclaimContended(err error) bool {
var errno syscall.Errno
return errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED)
}
func openLockFileHandleAt(parent windows.Handle, name string) (windows.Handle, error) {
for attempt := 0; attempt < 10; attempt++ {
handle, err := openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == nil {
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
// RemoveLockFile removes a lock file on Windows, retrying on the sharing
// violation or access denied errors that are common under heavy concurrent
// contention. Removing an already-missing file reports nil, matching the
// non-Windows implementation, so callers see one cross-platform contract.
func RemoveLockFile(path string) error {
var err error
for i := 0; i < 15; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if !windowsLockPathMissing(err) {
return 0, err
}
handle, err = openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_CREATE,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == windows.STATUS_OBJECT_NAME_COLLISION || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
var errno syscall.Errno
if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) {
time.Sleep(5 * time.Millisecond)
continue
}
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
return err
}
return 0, errors.New("lock file creation did not stabilize")
}
func windowsLockPathMissing(err error) bool {
return err == windows.STATUS_NO_SUCH_FILE ||
err == windows.STATUS_OBJECT_NAME_NOT_FOUND ||
err == windows.STATUS_OBJECT_PATH_NOT_FOUND ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND)
}
func openLockPathAt(parent windows.Handle, name string, access, disposition, attributes, options uint32) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
objectAttributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var handle windows.Handle
err = windows.NtCreateFile(
&handle,
access,
objectAttributes,
&windows.IO_STATUS_BLOCK{},
nil,
attributes,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
disposition,
options,
0,
0,
)
return handle, err
}
func validateWindowsLockHandle(handle windows.Handle, wantDirectory bool) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect lock path handle: %w", err)
}
isDirectory := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || isDirectory != wantDirectory {
return errors.New("refusing non-file or reparse-point lock path")
}
if !wantDirectory && info.NumberOfLinks != 1 {
return errors.New("refusing multiply-linked lock file")
}
return nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
state := platformLockState{overlapped: windows.Overlapped{OffsetHigh: lockByteOffsetHigh}}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &state.overlapped)
if err == nil {
return state, false, nil
}
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, state *platformLockState) error {
if file == nil {
return nil
}
return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &state.overlapped)
return err
}
@@ -0,0 +1,83 @@
//go:build windows
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist pins the errno coupling
// every caller keys its cleanup on: with no flags, MoveFileEx fails with
// ERROR_ALREADY_EXISTS when the destination exists, and that errno must
// satisfy errors.Is(err, os.ErrExist).
func TestMoveFileNoReplaceMapsAlreadyExistsToErrExist(t *testing.T) {
dir := t.TempDir()
from := filepath.Join(dir, "from")
to := filepath.Join(dir, "to")
if err := os.WriteFile(from, []byte("sidelined"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(to, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := moveFileNoReplace(from, to)
if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
t.Fatalf("MoveFileEx with an existing destination = %v, want ERROR_ALREADY_EXISTS", err)
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("ERROR_ALREADY_EXISTS must satisfy errors.Is(err, os.ErrExist), got: %v", err)
}
if data, _ := os.ReadFile(to); string(data) != "competing" {
t.Fatalf("destination was overwritten: %q", data)
}
}
// TestRemoveLockFileRetriesSharingViolation holds the file open briefly (Go
// opens files without FILE_SHARE_DELETE on Windows, so a concurrent DeleteFile
// fails with ERROR_SHARING_VIOLATION while the handle is open); RemoveLockFile
// must ride its retry loop (15 x 5ms) past the window and succeed once the
// handle closes.
func TestRemoveLockFileRetriesSharingViolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(10 * time.Millisecond)
_ = f.Close()
}()
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile should succeed once the contending handle closes: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
}
// TestRemoveLockFileSharingViolationExhausts verifies the retry loop is
// bounded: with the contending handle held for the whole call, RemoveLockFile
// surfaces the sharing violation instead of retrying forever.
func TestRemoveLockFileSharingViolationExhausts(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := RemoveLockFile(path); !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
t.Fatalf("RemoveLockFile with a held handle = %v, want ERROR_SHARING_VIOLATION", err)
}
}
+82
View File
@@ -0,0 +1,82 @@
package lockutil
import (
"errors"
"os"
)
// restoreLockFile is swappable so tests can force the fail-closed path of
// ReclaimStaleLock, which requires both the fast restore and its no-replace
// fallback (with its own copy fallback) to fail; that cannot be provoked
// portably on a healthy filesystem.
var restoreLockFile = restoreLiveLock
// restoreLiveLock puts a lock that turned out to be live back at path after
// ReclaimStaleLock moved it aside to inspect it. It first tries a fast,
// replacing rename straight from reclaimed to path: a single syscall, which
// keeps the window during which path does not exist (and so is open to a
// fourth process's unrelated O_EXCL create landing on it) as short as
// possible. RestoreLockFile's no-replace restore (with its slower copy
// fallback on some failures) is a correctness-preserving fallback for when
// the fast path itself fails (e.g. a cross-device sidelined name): it is a
// much longer version of the identical race, but still detects rather than
// silently clobbers a competing lock, which the fast path's replacing rename
// cannot do. Neither path makes the race impossible, only unlikely; see
// ReclaimStaleLock's doc comment.
func restoreLiveLock(reclaimed, path string) error {
if err := os.Rename(reclaimed, path); err == nil {
return nil
}
return RestoreLockFile(reclaimed, path)
}
// ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames
// lockPath aside to "<lockPath>.stale.<suffix>" (only one racer can win the
// rename of a given file, so two racers can never both reclaim the same lock),
// then consults isLive on the moved file; if the lock turns out to be live (a
// holder reacquired it in the gap between the caller's stale check and the
// rename) it is restored rather than stolen. The suffix must be unique per
// acquirer attempt. Returns true only when a genuinely stale lock was removed,
// so the caller knows it is safe to retry its exclusive create immediately; on
// a lost race it returns false. A non-nil error means either the rename aside
// failed for a reason that is not contention (so retrying cannot help and the
// caller should fail fast instead of spinning to its deadline), or a live
// holder's lock could not be restored (both restoreLiveLock's fast path and
// its no-replace fallback failed), so lockPath may be missing; callers must
// fail closed instead of re-acquiring. The sidelined file is removed on every
// restore failure: once the restore has failed it has no protocol function
// (release only consults the lock path), so keeping it would only leak files.
//
// The live-restore path has an inherent, unclosed race: between the rename
// aside above and restoreLiveLock putting the lock back, lockPath does not
// exist, so an unrelated caller's O_EXCL create can legitimately succeed
// there. restoreLiveLock's fast path then silently overwrites that new
// claimant's lock file, which does not corrupt release (it is
// ownership-aware, so the new claimant's later release safely no-ops against
// content it no longer owns) but does mean the new claimant can still run
// its critical section concurrently with the original live holder. Making
// this race actually impossible would need an OS-level advisory lock (flock
// / LockFileEx) held for a holder's whole critical section, checked
// non-destructively instead of by moving the file; restoreLiveLock only
// shrinks the window to roughly one syscall, it does not close it.
func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) {
reclaimed := lockPath + ".stale." + suffix
if err := os.Rename(lockPath, reclaimed); err != nil {
if errors.Is(err, os.ErrNotExist) || isReclaimContended(err) {
return false, nil // another racer already moved/removed it, or it vanished
}
return false, err
}
if isLive(reclaimed) {
// Put the live lock back instead of stealing it, and let the caller wait.
if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil {
_ = RemoveLockFile(reclaimed)
if !errors.Is(rerr, os.ErrExist) {
return false, rerr
}
}
return false, nil
}
_ = RemoveLockFile(reclaimed)
return true, nil
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside: a failure that is not a lost race must reach the caller.
// Mapping it to the benign lost-race result would leave acquirers spinning
// until their deadline and reporting a timeout instead of the real cause. A
// directory squatting on the sidelined name makes os.Rename fail with EISDIR,
// which matches neither os.ErrNotExist nor a contention errno.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(lockPath+".stale.tok", 0o755); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestReclaimStaleLock(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
dead := func(string) bool { return false }
live := func(string) bool { return true }
// A lock the predicate reports dead is reclaimed and removed.
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-a", dead); err != nil || !ok {
t.Fatalf("a dead lock should be reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("reclaimed dead lock should be gone, stat err=%v", err)
}
// A LIVE lock (a holder reacquired in the gap) must be RESTORED intact.
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-b", live); err != nil || ok {
t.Fatalf("a live lock must not be reclaimed (ok=%v err=%v)", ok, err)
}
if data, err := os.ReadFile(lockPath); err != nil || string(data) != "live-holder" {
t.Fatalf("live lock must be left intact, got %q err %v", data, err)
}
// A missing lock reports no reclaim (nothing to steal).
_ = os.Remove(lockPath)
if ok, err := ReclaimStaleLock(lockPath, "tok-c", live); err != nil || ok {
t.Fatalf("a missing lock should not report a reclaim (ok=%v err=%v)", ok, err)
}
}
// restoreLiveLock's fast path is a single replacing rename, chosen to
// minimize (not eliminate) the window during which lockPath is absent and
// open to an unrelated O_EXCL create; see ReclaimStaleLock's doc comment for
// why that residual race exists. This test pins the resulting behavior: the
// fast path overwrites a competing file at path rather than detecting it,
// unlike RestoreLockFile's own no-replace contract.
func TestRestoreLiveLockFastPathOverwritesCompetingFile(t *testing.T) {
dir := t.TempDir()
reclaimed := filepath.Join(dir, "lock.stale.tok")
path := filepath.Join(dir, "lock")
if err := os.WriteFile(reclaimed, []byte("original-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("new-claimant"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreLiveLock(reclaimed, path); err != nil {
t.Fatalf("restoreLiveLock failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "original-holder" {
t.Fatalf("path = %q, err %v; want the original holder's content restored", data, err)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected reclaimed to be consumed by the rename: %v", err)
}
}
func TestReclaimStaleLockFailsClosedOnRestoreError(t *testing.T) {
// When both the no-replace restore and its copy fallback fail (only
// provokable via the seam; a healthy filesystem cannot produce it), the
// caller must receive an error so it fails closed instead of re-acquiring a
// missing lock path, and the sidelined file must not leak.
restoreLockFile = func(reclaimed, path string) error { return errors.New("restore failed") }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err == nil || ok {
t.Fatalf("a failed restore must surface an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("a failed restore must not leak sidelined files: %v", matches)
}
}
func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
// An os.ErrExist restore failure means a new holder recreated the lock
// path; that is not an error for the caller, and the sidelined file is
// dropped rather than leaked.
restoreLockFile = func(reclaimed, path string) error { return os.ErrExist }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err != nil || ok {
t.Fatalf("losing to a new holder is not an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("the sidelined file must be dropped when a new holder wins: %v", matches)
}
}
+50
View File
@@ -0,0 +1,50 @@
//go:build windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockTreatsSharingViolationAsLostRace pins the errno
// classification the Windows acquire loops depend on: a rename aside that
// fails because the lock file is concurrently open (Go opens files without
// FILE_SHARE_DELETE, so the rename fails with ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED) is contention, not a hard failure, and must report a
// benign lost race so the caller keeps waiting.
func TestReclaimStaleLockTreatsSharingViolationAsLostRace(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("held"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(lockPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if ok, rerr := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true }); rerr != nil || ok {
t.Fatalf("a sharing-violation rename must report a benign lost race (ok=%v err=%v)", ok, rerr)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place: %v", err)
}
}
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside on Windows: a failure that is not contention must reach
// the caller instead of masquerading as a lost race. A quote is an invalid
// filename character, so the sidelined name fails with ERROR_INVALID_NAME.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, `bad"name`, func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+64 -7
View File
@@ -5,16 +5,24 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
const fileLockTimeout = 5 * time.Second
const (
fileLockTimeout = 5 * time.Second
fileLockStaleAfter = 30 * time.Second
)
// acquireFileLock takes a cross-process advisory lock through a stable lock
// file. It retries with a short backoff until a timeout. Kernel lock lifetime
// provides crash recovery, so the path is never renamed or removed.
var lockSeq atomic.Uint64
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
// store). Release is ownership-aware: it removes the lock only if it still holds
// our token, so a stale-broken holder cannot delete a newer holder's lock.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if now == nil {
now = time.Now
@@ -22,15 +30,64 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial/failed write would leave a lock file without our token, so
// the releaser below could never delete it — stranding the lock for
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if now().After(deadline) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
func EnsurePrivateDir(path string) error {
func ensurePrivateDir(path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"golang.org/x/sys/unix"
)
func EnsurePrivateDir(path string) error {
func ensurePrivateDir(path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
+2 -2
View File
@@ -18,7 +18,7 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := EnsurePrivateDir(link); err == nil {
if err := ensurePrivateDir(link); err == nil {
t.Fatal("expected symlink runtime directory to be rejected")
}
}
@@ -33,7 +33,7 @@ func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
t.Fatal("expected symlinked parent to be rejected")
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"golang.org/x/sys/windows"
)
func EnsurePrivateDir(path string) (resultErr error) {
func ensurePrivateDir(path string) (resultErr error) {
abs, err := filepath.Abs(path)
if err != nil {
return err
+5 -5
View File
@@ -54,7 +54,7 @@ func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
if !windowsDirectoryDACLContains(t, path, worldSID) {
t.Fatal("test setup did not grant the broad Everyone ACE")
}
if err := EnsurePrivateDir(path); err != nil {
if err := ensurePrivateDir(path); err != nil {
t.Fatal(err)
}
descriptor, err := windows.GetNamedSecurityInfo(
@@ -149,7 +149,7 @@ func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "private")
if err := EnsurePrivateDir(path); err != nil {
if err := ensurePrivateDir(path); err != nil {
t.Fatal(err)
}
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
@@ -172,7 +172,7 @@ func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "private")
if err := EnsurePrivateDir(path); err != nil {
if err := ensurePrivateDir(path); err != nil {
t.Fatal(err)
}
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
@@ -206,7 +206,7 @@ func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create Windows directory symlink: %v", err)
}
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
t.Fatal("expected reparse-point parent to be rejected")
}
}
@@ -217,7 +217,7 @@ func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := EnsurePrivateDir(filepath.Join(file, "peers")); err == nil {
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
t.Fatal("expected file path component to be rejected")
}
}
+3 -3
View File
@@ -158,7 +158,7 @@ func New(options Options) (*Service, error) {
}
// canonicalRuntimePath normalizes aliases in the existing prefix. It does not
// establish a security boundary; EnsurePrivateDir validates that boundary when
// establish a security boundary; ensurePrivateDir validates that boundary when
// the service starts.
func canonicalRuntimePath(path string) (string, error) {
missing := make([]string, 0, 4)
@@ -219,10 +219,10 @@ func (service *Service) Start(handler Handler) error {
if service.closed {
return errors.New("peer messaging: service is closed")
}
if err := EnsurePrivateDir(service.root); err != nil {
if err := ensurePrivateDir(service.root); err != nil {
return fmt.Errorf("peer messaging: create runtime directory: %w", err)
}
if err := EnsurePrivateDir(service.registryDir()); err != nil {
if err := ensurePrivateDir(service.registryDir()); err != nil {
return fmt.Errorf("peer messaging: create registry: %w", err)
}
endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid)
+1 -1
View File
@@ -44,7 +44,7 @@ func canonicalPrivateDir(path string) (string, error) {
if err != nil {
return "", err
}
if err := EnsurePrivateDir(canonical); err != nil {
if err := ensurePrivateDir(canonical); err != nil {
return "", err
}
return canonical, nil
-108
View File
@@ -1,108 +0,0 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
-113
View File
@@ -1,113 +0,0 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
-2
View File
@@ -7,7 +7,6 @@ import (
type BackendOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
DetectWSL func() WSLInfo
}
type Backend struct {
@@ -54,7 +53,6 @@ func SelectBackend(options BackendOptions) Backend {
return NewSandboxManager(SandboxManagerOptions{
GOOS: options.GOOS,
LookupExecutable: options.LookupExecutable,
DetectWSL: options.DetectWSL,
}).Backend()
}
-15
View File
@@ -45,7 +45,6 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
}
return "", errors.New("missing")
},
DetectWSL: func() WSLInfo { return WSLInfo{} },
})
if backend.Name != BackendUnavailable || backend.Available {
t.Fatalf("linux backend = %#v, want native sandbox unavailable without Linux helper", backend)
@@ -55,20 +54,6 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
}
})
t.Run("linux helper missing under WSL reports WSL fallback", func(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "linux",
LookupExecutable: func(string) (string, error) { return "", errors.New("missing") },
DetectWSL: func() WSLInfo { return WSLInfo{IsWSL: true, IsWSL2: true} },
})
if backend.Name != BackendWSL || backend.Available || !backend.Fallback {
t.Fatalf("linux backend = %#v, want explicit WSL fallback", backend)
}
if !strings.Contains(backend.Message, "WSL2") {
t.Fatalf("linux fallback message = %q, want WSL2 diagnostic", backend.Message)
}
})
t.Run("darwin sandbox exec available", func(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "darwin",
+3 -7
View File
@@ -51,7 +51,6 @@ const (
type SandboxManagerOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
DetectWSL func() WSLInfo
Backend Backend
}
@@ -97,7 +96,7 @@ func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
goos = runtime.GOOS
}
if backend.Name == "" {
backend = selectPlatformBackend(goos, options.LookupExecutable, options.DetectWSL)
backend = selectPlatformBackend(goos, options.LookupExecutable)
}
if backend.Platform == "" {
backend.Platform = goos
@@ -169,13 +168,10 @@ func lookupExecutable(name string) (string, error) {
return "", errors.New("executable file not found")
}
func selectPlatformBackend(goos string, lookup func(string) (string, error), detect func() WSLInfo) Backend {
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
if lookup == nil {
lookup = lookupExecutable
}
if detect == nil {
detect = detectWSL
}
switch goos {
case "linux":
if helper, err := lookup(LinuxSandboxHelperName); err == nil && helper != "" {
@@ -184,7 +180,7 @@ func selectPlatformBackend(goos string, lookup func(string) (string, error), det
}
return nativeBackend(goos, BackendLinuxBwrap, helper, "Linux sandbox helper available")
}
if info := detect(); info.IsWSL {
if info := detectWSL(); info.IsWSL {
return wslBackend(goos, info)
}
return unavailableBackend(goos, "Linux sandbox helper is not available")
+3 -92
View File
@@ -96,104 +96,15 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"}
// gitMetadataWriteCarveouts below.
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
// sandboxRenameProtectedMetadataName is the metadata directory that cannot be
// fully write-protected but must still not be REPLACEABLE.
//
// It is deliberately not in the list above. That list denies write, and git has
// to write index, objects and refs. But the carveouts guarding it are attached
// to .git/config and .git/hooks as objects, so a principal that renames .git and
// recreates it gets fresh paths inheriting the workspace allow with no denies,
// which restores credential.helper and core.hooksPath. The Windows ACL plan
// therefore denies DELETE on this directory alone, uninherited.
const sandboxRenameProtectedMetadataName = ".git"
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
// under the OS-level sandbox even though the rest of .git is writable to git
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
func gitMetadataWriteCarveouts(root string) []string {
specs := gitMetadataWriteCarveoutSpecs(root)
out := make([]string, 0, len(specs))
for _, spec := range specs {
out = append(out, spec.Path)
return []string{
filepath.Join(root, ".git", "hooks"),
filepath.Join(root, ".git", "config"),
}
return out
}
// gitMetadataCarveout is a write-denied .git path together with the shape git
// expects it to have. The shape matters to exactly one backend: the Windows ACL
// plan creates a missing carveout so the deny ACE is in place before git first
// runs, and creating .git/config as a directory makes `git init` fail outright.
// Every other backend only ever names the path, so it can ignore IsFile.
type gitMetadataCarveout struct {
Path string
IsFile bool
}
// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout
// set. gitMetadataWriteCarveouts derives its list from this so a path can never
// be added in one place and have its shape forgotten in the other.
func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout {
gitPath := filepath.Join(root, ".git")
// A LINKED WORKTREE OR SUBMODULE HAS .git AS A FILE, NOT A DIRECTORY.
//
// It holds a `gitdir:` pointer to the real control directory, which lives
// outside this write root. Naming .git/config and .git/hooks there asks the
// Windows plan to materialize them by descending through .git as a directory,
// which cannot open a child beneath a regular file, so opted-in elevated setup
// aborts and the sandbox is unusable in any worktree. Zero's own development
// worktrees are exactly this shape.
//
// Deny the pointer file itself instead. It is the right protection rather than
// a lesser one: a principal that can rewrite `gitdir:` repoints the whole
// repository at a control directory it chooses, which subsumes editing config
// or dropping a hook. The real control directory is outside the write root, so
// the principal has no inherited access to it and needs no carveout there.
//
// Stat, not a lexical guess. Whether .git is a file is a property of the
// checkout, and the two layouts want different ACEs. A missing .git (git has
// not run yet) keeps the directory-shaped carveouts, which is what makes them
// materialize before git first runs.
if info, err := os.Lstat(gitPath); err == nil && !info.IsDir() {
return []gitMetadataCarveout{{Path: gitPath, IsFile: true}}
}
return []gitMetadataCarveout{
{Path: filepath.Join(root, ".git", "hooks")},
{Path: filepath.Join(root, ".git", "config"), IsFile: true},
}
}
// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the
// trailing segments of the carveout specs. It is never touched on disk.
const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base"
// gitMetadataCarveoutIsFile reports whether path names a carveout git expects
// to be a file.
//
// It matches on the trailing segments rather than on a whole reconstructed
// path. The subpaths reaching the ACL plan are already normalized — resolved
// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot
// be, because .git/config does not exist yet at setup and resolution falls back
// to a plain Clean. On a host where two spellings of the same path differ (an
// 8.3 short name, different casing) a whole-path equality check silently misses
// and the carveout is created as a directory again, which is the original bug
// reintroduced quietly. The suffix cannot drift from the spec list because it
// is derived from it.
func gitMetadataCarveoutIsFile(path string) bool {
candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path)))
if candidate == "" {
return false
}
for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) {
if !spec.IsFile {
continue
}
suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase))
if suffix != "" && strings.HasSuffix(candidate, suffix) {
return true
}
}
return false
}
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
-79
View File
@@ -1,79 +0,0 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A LINKED WORKTREE HAS .git AS A FILE.
//
// The Windows principal plan materializes every carveout, and it does so by
// descending through .git as a directory. Against a `gitdir:` pointer file that
// walk cannot open a child, so opted-in elevated setup aborts and the sandbox
// cannot be used in a worktree at all. Zero's own development worktrees have
// exactly this layout, so this is the common case for anyone working on the
// sandbox itself.
func TestGitCarveoutsHandleAWorktreeGitfile(t *testing.T) {
root := t.TempDir()
gitFile := filepath.Join(root, ".git")
// The real shape git writes for a linked worktree.
if err := os.WriteFile(gitFile, []byte("gitdir: "+filepath.Join(root, "..", "main", ".git", "worktrees", "wt")+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
specs := gitMetadataWriteCarveoutSpecs(root)
if len(specs) != 1 {
t.Fatalf("carveout specs = %#v, want only the .git pointer file", specs)
}
if filepath.Clean(specs[0].Path) != filepath.Clean(gitFile) {
t.Fatalf("carveout path = %s, want the .git pointer file %s", specs[0].Path, gitFile)
}
if !specs[0].IsFile {
t.Fatal("the .git pointer carveout is not marked as a file, so materialization would create a directory where git needs a file")
}
// Nothing may name a path BENEATH the pointer file, which is what forces the
// directory descent that aborts setup.
for _, path := range gitMetadataWriteCarveouts(root) {
rest := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(gitFile))
if rest != "" {
t.Errorf("carveout %s descends through the .git pointer file, which cannot have children", path)
}
}
}
// The ordinary repository layout must keep the config and hooks carveouts. They
// are the whole reason the set exists: without them a principal reinstates
// credential.helper or core.hooksPath through inherited workspace write access.
func TestGitCarveoutsKeepConfigAndHooksForARealGitDir(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
paths := gitMetadataWriteCarveouts(root)
for _, want := range []string{filepath.Join(root, ".git", "hooks"), filepath.Join(root, ".git", "config")} {
found := false
for _, got := range paths {
if filepath.Clean(got) == filepath.Clean(want) {
found = true
break
}
}
if !found {
t.Errorf("carveout %s missing for an ordinary .git directory: %#v", want, paths)
}
}
}
// A workspace where git has never run keeps the directory-shaped carveouts. That
// is what lets the Windows plan create them before git first runs, so the deny
// ACEs are already in place rather than being applied to paths git will create
// later with inherited write access.
func TestGitCarveoutsKeepConfigAndHooksWhenGitIsAbsent(t *testing.T) {
root := t.TempDir()
paths := gitMetadataWriteCarveouts(root)
if len(paths) != 2 {
t.Fatalf("carveouts = %#v, want the config and hooks pair when .git is absent", paths)
}
}
@@ -352,15 +352,6 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
t.Helper()
// options.PrincipalOptIn is deliberately left nil by both call sites, which
// makes BuildWindowsSandboxSetupArgs resolve the opt-in from this process's
// environment — the same value the command half resolves, since the smoke
// WindowsSandboxCommandArgsOptions carries no explicit entry either. Do not
// "fix" this by setting it to false: anyone running this suite with
// ZERO_WINDOWS_SANDBOX_IDENTITY=1 (the only way to exercise the principal
// backend) would then serialize `--sandbox-principal 0`, disagree with the
// command half, and fail every command at marker validation instead of
// testing the sandbox.
args, err := BuildWindowsSandboxSetupArgs(options)
if err != nil {
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
@@ -1,33 +0,0 @@
//go:build !windows
package sandbox
import (
"os"
"path/filepath"
)
// physicalTempDir returns os.TempDir with every symlink in its ancestry
// resolved, so the fallback anchor's parent chain is physical and only the
// anchor itself is a new component.
//
// peermsg.EnsurePrivateDir walks from the root with O_NOFOLLOW and refuses any
// component that is a link. That is right for the owned tail and wrong for the
// ancestors above it: on macOS os.TempDir lives under /var, which is a symlink
// to /private/var, so the first version of this fix refused every fallback on
// every Mac with "refusing non-directory or symlink runtime path component
// var". The constraint jatmn stated for #901 applies here identically:
// redirected cache and TEMP locations above the owned tail are the operator's
// business and must keep working; the restriction is on what Zero owns.
//
// EvalSymlinks is the correct resolver off Windows, where the only reparse
// shape is a symlink and it traverses them.
func physicalTempDir() (string, error) {
return physicalDir(os.TempDir())
}
// physicalDir resolves any directory the same way, for the other places that
// have to hand EnsurePrivateDir a physical parent.
func physicalDir(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
@@ -1,63 +0,0 @@
//go:build windows
package sandbox
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
// physicalTempDir returns os.TempDir as the object it actually names, so the
// fallback anchor's parent chain is physical and only the anchor itself is a
// new component.
//
// peermsg.EnsurePrivateDir walks from the volume root with OBJ_DONT_REPARSE and
// refuses any component that is a reparse point. That is right for the owned
// tail and wrong for the ancestors above it: a redirected %LOCALAPPDATA% is an
// ordinary Windows configuration, and TEMP sits beneath it. The constraint
// jatmn stated for #901 applies here identically: redirected cache and TEMP
// locations above the owned tail must keep working; the restriction is on what
// Zero owns.
//
// GetFinalPathNameByHandle rather than filepath.EvalSymlinks, because
// EvalSymlinks does not traverse a junction on Windows: it returns the
// junction's own path and the walker then refuses it. The handle answers where
// the directory actually is. Same recipe as verifyWindowsACLTargetNotRedirected,
// which is why the flag constants and the prefix trim are shared.
func physicalTempDir() (string, error) {
return physicalDir(os.TempDir())
}
// physicalDir resolves any directory through a handle, for the other places
// that have to hand EnsurePrivateDir a physical parent. GetFinalPathNameByHandle
// rather than EvalSymlinks, because EvalSymlinks does not traverse a junction.
func physicalDir(path string) (string, error) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", fmt.Errorf("encode directory %s: %w", path, err)
}
handle, err := windows.CreateFile(
utf16Path,
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return "", fmt.Errorf("open directory %s: %w", path, err)
}
defer windows.CloseHandle(handle)
buffer := make([]uint16, windows.MAX_LONG_PATH)
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS)
if err != nil {
return "", fmt.Errorf("resolve directory %s: %w", path, err)
}
if int(n) < len(buffer) {
buffer = buffer[:n]
}
return trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)), nil
}
@@ -1,155 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// DETERMINISM IS NOT OWNERSHIP.
//
// The fallback runtime root moved from a fresh os.MkdirTemp parent to a
// predictable name so that elevated setup and the later command agree on it.
// That is required. But a predictable path beneath the shared system temp is
// not a private allocation: whatever another local user, or a stray earlier
// process, has already placed at that name is what preparation then leases,
// creates, chmods and cleans through. The anchor beneath which the tree lives
// must therefore be proven to be this user's private directory before anything
// is built under it, on BOTH paths that can hand back a fallback root.
//
// Junctions need no privilege on Windows, so the "attacker-precreated parent"
// jatmn described is modelled as a junction at the anchor pointing into a
// directory the test owns and watches. Either trigger must refuse, and the
// watched directory must stay empty.
// fallbackAnchorFixture redirects TEMP so the anchor lands inside a directory
// this test owns, plants a junction at the anchor into a watched target, and
// returns the target. The junction is the shape that defeats a pathname walk:
// it is not a symlink to os.ModeSymlink, and EvalSymlinks will not traverse it.
func fallbackAnchorFixture(t *testing.T) (target string) {
t.Helper()
tempRoot := t.TempDir()
target = t.TempDir()
t.Setenv("TMP", tempRoot)
t.Setenv("TEMP", tempRoot)
// The anchor's parent is the PHYSICAL temp dir, so compare against the
// resolved form of the redirect rather than its spelling: t.TempDir can
// come back as an 8.3 short name or in a different case from what the
// handle reports, and a prefix check on the raw string would fail for a
// correct anchor.
anchor := fallbackRuntimeAnchor()
resolvedTempRoot, err := physicalTempDir()
if err != nil {
t.Fatalf("SETUP INVALID: cannot resolve the redirected TEMP %s: %v", tempRoot, err)
}
if !pathWithinRoot(resolvedTempRoot, anchor) {
t.Fatalf("SETUP INVALID: anchor %s is not under the redirected TEMP %s (resolved %s)", anchor, tempRoot, resolvedTempRoot)
}
if out, err := exec.Command("cmd", "/c", "mklink", "/J", anchor, target).CombinedOutput(); err != nil {
t.Fatalf("mklink /J: %v\n%s", err, out)
}
return target
}
func assertNothingUnder(t *testing.T, target string) {
t.Helper()
entries, err := os.ReadDir(target)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Errorf("preparation built %v beneath the junction target, which is somebody else's directory", names)
}
}
// Trigger one: the user cache resolves INSIDE the workspace, so
// sandboxRuntimeRootFor itself returns the fallback root and the first lease
// attempt is already against it.
func TestFallbackAnchorIsRefusedWhenTheCacheLandsInsideTheWorkspace(t *testing.T) {
target := fallbackAnchorFixture(t)
workspace := t.TempDir()
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
_, release, err := prepareSandboxRuntime(workspace)
if err == nil {
release()
t.Fatal("preparation went through a junction at the fallback anchor and reported success")
}
if !strings.Contains(err.Error(), "fallback anchor") {
t.Errorf("the refusal does not name the anchor, so the operator cannot act on it: %v", err)
}
assertNothingUnder(t, target)
}
// Trigger two: the cache root cannot be leased (a FILE sits where the cache
// runtime tree would go), so preparation falls back explicitly.
func TestFallbackAnchorIsRefusedWhenTheCacheRootCannotBeLeased(t *testing.T) {
target := fallbackAnchorFixture(t)
workspace := t.TempDir()
// Make the cache-derived root unleasable at its ROOT: the user cache is a
// regular file, so nothing beneath it can be created and the first lease
// fails, which is the path that asks for the fallback explicitly. The
// first draft planted the file at the derived leaf instead, and the
// preparation failed on that mkdir before ever reaching the fallback
// branch, so the trigger under test never fired.
cache := filepath.Join(t.TempDir(), "cache-is-a-file")
if err := os.WriteFile(cache, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return cache, nil }
_, release, err := prepareSandboxRuntime(workspace)
if err == nil {
release()
t.Fatal("preparation fell back through a junction at the anchor and reported success")
}
if !strings.Contains(err.Error(), "fallback anchor") {
t.Errorf("the refusal does not name the anchor: %v", err)
}
assertNothingUnder(t, target)
}
// And the honest control: with nothing planted, the fallback prepares, the
// anchor is a real directory this test can see, and the runtime tree sits
// beneath it.
func TestFallbackAnchorIsCreatedPrivatelyWhenNothingIsInTheWay(t *testing.T) {
tempRoot := t.TempDir()
t.Setenv("TMP", tempRoot)
t.Setenv("TEMP", tempRoot)
workspace := t.TempDir()
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("a clean fallback was refused: %v", err)
}
defer release()
anchor := fallbackRuntimeAnchor()
info, err := os.Lstat(anchor)
if err != nil || !info.IsDir() {
t.Fatalf("the anchor was not created as a directory: err=%v", err)
}
if !pathWithinRoot(anchor, runtimeState.Root) {
t.Errorf("runtime root %s is not beneath the anchor %s", runtimeState.Root, anchor)
}
}
-85
View File
@@ -1,85 +0,0 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
// SETUP AND EVERY COMMAND RUNNER MUST DERIVE THE SAME FALLBACK ROOT.
//
// They are separate processes. The fallback used to call os.MkdirTemp and cache
// the answer in a process-global map, so elevated setup granted the sandbox
// principal write access to the directory IT made, and each later
// __windows-command-runner made a different one and pointed TMP, GOCACHE and npm
// there. Those directories are created by the calling user and carry no ACE for
// the principal, so ordinary cache writes failed with a bare ACCESS_DENIED.
//
// A repeated call is what a second process looks like from here: no shared
// state, same inputs. The old implementation could not pass this, because the
// only thing that made repeat calls agree was the in-process map.
func TestFallbackRuntimeRootIsTheSameForEveryProcess(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "ws")
first, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
}
second, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("fallbackSandboxRuntimeRoot (second process): %v", err)
}
if first != second {
t.Fatalf("two processes derived different runtime roots, so setup grants one and commands write to another:\n %s\n %s", first, second)
}
if first == "" {
t.Fatal("no runtime root derived")
}
}
// Different workspaces must not share a runtime tree, or one workspace's
// principal would hold an ACE on another's caches.
func TestFallbackRuntimeRootIsPerWorkspace(t *testing.T) {
base := t.TempDir()
first, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "alpha"))
if err != nil {
t.Fatal(err)
}
second, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "beta"))
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("two workspaces share the runtime root %q", first)
}
}
// It must NAME the tree without creating it. Teardown asks for this path on its
// way out, and materializing a directory there would leave one behind on every
// teardown.
func TestFallbackRuntimeRootCreatesNothing(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "ws")
root, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatal(err)
}
if pathExists(root) {
t.Errorf("naming the runtime root created %s", root)
}
}
// The root must land outside the workspace, which is the entire reason this
// branch exists.
func TestFallbackRuntimeRootStaysOutsideTheWorkspace(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "ws")
root, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatal(err)
}
if pathWithinRoot(workspace, root) {
t.Errorf("runtime root %q is inside workspace %q", root, workspace)
}
if !strings.Contains(root, "zero") {
t.Errorf("runtime root %q does not look like a zero-owned tree", root)
}
}
+28 -215
View File
@@ -7,14 +7,10 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/peermsg"
)
var sandboxUserCacheDir = os.UserCacheDir
@@ -25,6 +21,11 @@ const (
sandboxRuntimeMaxRoots = 64
)
var fallbackSandboxRuntimes = struct {
sync.Mutex
roots map[string]string
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Cache string `json:"cache,omitempty"`
@@ -32,37 +33,8 @@ type SandboxRuntime struct {
Temp string `json:"temp,omitempty"`
}
// sandboxRuntimeRootFor derives the per-workspace runtime root. It is separated
// from prepareSandboxRuntime because the elevated Windows setup path needs the
// same answer WITHOUT taking a lease or creating anything: a sandbox principal
// is a separate account with no inherited rights under the user cache, so setup
// has to grant it write access to this tree before any command runs.
//
// Both callers must agree exactly. If they ever drift, setup grants the ACE on
// one directory while commands write to another, and the failure is a bare
// ACCESS_DENIED from npm or go build with nothing pointing at the sandbox.
func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, error) {
if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok {
return root, nil
}
return fallbackSandboxRuntimeRoot(workspaceRoot)
}
// deterministicSandboxRuntimeRoot returns the cache-derived runtime root and
// whether it is usable, meaning it lands outside the workspace.
//
// Neither this nor the fallback creates anything now, so a caller that only
// needs to NAME the tree can safely go through sandboxRuntimeRootFor and get the
// answer commands will actually use. This remains separate for callers that need
// to distinguish the cache-derived root from the temp-derived one.
func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) {
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
return root, !pathWithinRoot(workspaceRoot, root)
}
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot)
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
if workspaceRoot == "" || workspaceRoot == "." {
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
}
@@ -70,30 +42,17 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
if err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
}
// Canonicalized the SAME way as the workspace root, because
// sandboxRuntimeRootFor compares the two: it falls back to a private temp
// tree when the derived runtime root would land inside the workspace.
// Normalizing only one side made that comparison run on two different
// spellings of the same path — /var vs /private/var on macOS, an 8.3 short
// name vs its long form on Windows — so the containment check missed and the
// fallback never fired.
cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot)
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
if cacheRoot == "" || cacheRoot == "." {
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
}
root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
// THE ANCHOR IS PROVEN BEFORE THE TREE IS BUILT, on both paths that can
// hand back a fallback root: sandboxRuntimeRootFor returns one itself when
// the cache lands inside the workspace, and the branch below asks for one
// when the cache root cannot be leased. A deterministic name under the
// shared temp is only safe beneath a directory this user owns; if the anchor
// is a link, or exists and belongs to someone else, this refuses rather than
// leasing, creating and chmod-ing through it.
if err := ensureFallbackRuntimeAnchor(root); err != nil {
return SandboxRuntime{}, nil, err
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
@@ -101,9 +60,6 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
if err != nil {
return SandboxRuntime{}, nil, err
}
if err := ensureFallbackRuntimeAnchor(root); err != nil {
return SandboxRuntime{}, nil, err
}
lease, err = prepareSandboxRuntimeLease(root)
if err != nil {
return SandboxRuntime{}, nil, err
@@ -218,109 +174,22 @@ func combineSandboxCleanups(cleanups ...func()) func() {
}
}
// fallbackRuntimeAnchor is the per-user directory the deterministic fallback
// runtime tree lives beneath.
//
// DETERMINISM IS NOT OWNERSHIP. The fallback moved from a fresh, private
// os.MkdirTemp parent to a predictable name so that elevated setup and the
// later command process agree on the runtime root. That agreement is required
// and must stay. But a predictable path directly under the shared system temp
// is not a private allocation: on an ordinary shared /tmp another local user
// can create /tmp/zero with mode 0700 first, and every affected user then fails
// before the sandbox command starts; a pre-existing redirected component makes
// host-side preparation operate somewhere other than the derived tree.
//
// So the tree is rooted beneath an anchor that names THIS user, and the anchor
// is created and validated by peermsg.EnsurePrivateDir before anything is
// built under it: handle-relative descent, no-follow, refused if any component
// is a link or the leaf is not owned by the current user, mode 0700. On Unix
// the uid is in the name because /tmp is shared between users; on Windows
// os.TempDir is already per-user, and the private descriptor plus the owner
// check do the rest.
func fallbackRuntimeAnchor() string {
tag := "user"
if runtime.GOOS != "windows" {
tag = strconv.Itoa(os.Getuid())
}
// PHYSICAL PARENT, OWNED LEAF. The validator walks every component no-follow
// and refuses links, which is right for the anchor Zero owns and wrong for
// the operator's ancestors above it: macOS puts TempDir under /var, a
// symlink to /private/var, and a redirected %LOCALAPPDATA% is ordinary on
// Windows. The first version handed the validator the unresolved path and
// refused every fallback on every Mac. Resolving the parent first means the
// only new component the validator sees is the anchor itself.
//
// On a resolve error the unresolved path is used, deliberately: the
// validator is still the fail-closed check, and refusing there names the
// real component rather than hiding it behind a resolver failure.
base := os.TempDir()
if physical, err := physicalTempDir(); err == nil && physical != "" {
base = physical
}
return filepath.Join(base, "zero-runtime-"+tag)
}
// isFallbackRuntimeRoot reports whether root was derived by
// fallbackSandboxRuntimeRoot, so the caller knows the anchor must be validated
// before the tree beneath it is prepared. Both candidates a command derives can
// be fallback roots: sandboxRuntimeRootFor returns one itself when the cache
// lands inside the workspace, and prepareSandboxRuntime asks for one when the
// cache root cannot be leased.
func isFallbackRuntimeRoot(root string) bool {
// pathWithinRoot(parent, child): is root beneath the anchor. The first
// draft had these reversed, which asked whether the anchor was beneath the
// root, answered false every time, and skipped the validation entirely
// while every test that depended on it reported success through a
// junction. Argument order is the whole function.
return pathWithinRoot(fallbackRuntimeAnchor(), root)
}
// ensureFallbackRuntimeAnchor validates the anchor beneath which a fallback
// runtime tree is about to be built, and fails closed. Called only when the
// root is a fallback root; the cache-derived root lives under the user cache,
// which is already the user's own directory.
func ensureFallbackRuntimeAnchor(root string) error {
if !isFallbackRuntimeRoot(root) {
return nil
}
anchor := fallbackRuntimeAnchor()
if err := peermsg.EnsurePrivateDir(anchor); err != nil {
return fmt.Errorf("sandbox runtime fallback anchor %s is not a private directory owned by this user: %w", anchor, err)
}
return nil
}
// fallbackSandboxRuntimeRoot returns the runtime root for a workspace whose
// cache-derived root would land inside itself.
//
// DERIVED, not minted, and that is the whole of the fix. It used to call
// os.MkdirTemp and remember the answer in a process-global map, which made the
// result private to whichever process asked first. Elevated Windows setup
// granted the sandbox principal write access to the directory IT created, then
// every later __windows-command-runner process created a DIFFERENT one and
// pointed TMP, GOCACHE, npm and the rest at it. Those directories are created by
// the calling user and carry no ACE for the principal, so ordinary cache and
// temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox.
//
// sandboxRuntimeRootFor already documents that both callers must agree exactly.
// Hashing the workspace, the same way the cache-derived root does, is what makes
// that true for this branch too: every process reaches the same path without
// having to share any state.
//
// It creates nothing, so deterministicSandboxRuntimeRoot's promise about naming
// a tree without materializing it now holds for the fallback as well.
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(fallbackRuntimeAnchor(), "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
// Both candidates land inside the workspace, so there is nowhere left to
// put a runtime tree the workspace's own policy does not govern. Refused
// rather than pointed somewhere arbitrary: a runtime root inside the
// workspace makes the sandbox's own cache writes indistinguishable from
// the work it is meant to be confining.
return "", fmt.Errorf("sandbox runtime root %q would fall inside workspace %q; "+
"open the workspace somewhere other than the cache or temp directory", root, workspaceRoot)
fallbackSandboxRuntimes.Lock()
defer fallbackSandboxRuntimes.Unlock()
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
return root, nil
}
parent, err := os.MkdirTemp("", "zero-runtime-")
if err != nil {
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
}
root := filepath.Join(parent, "runtime")
if pathWithinRoot(workspaceRoot, root) {
_ = os.RemoveAll(parent)
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
}
fallbackSandboxRuntimes.roots[workspaceRoot] = root
return root, nil
}
@@ -357,59 +226,3 @@ func permissionProfileWithRuntime(profile PermissionProfile, runtimeState Sandbo
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
return profile
}
// canonicalSandboxWorkspaceRoot normalizes a workspace root the way
// Engine.resolveCommandDir already does — clean, absolutize, then resolve
// symlinks — so every derivation keyed to a workspace agrees on the string.
//
// The runtime root is a hash of this, and the elevated Windows setup grants the
// principal that tree while commands derive it again. Cleaning alone was not
// enough for the two to agree, and it does not take a symlink for them to
// differ: a path opened in different casing, or through an 8.3 short name (what
// a Windows CI runner's TEMP looks like), resolves to a different spelling.
// Setup then granted one tree and every command used another, so the grant that
// makes npm/go/pip caches writable landed where nothing reads and surfaced as a
// bare ACCESS_DENIED.
//
// Resolution failing is not an error: an unresolvable root still needs a stable
// key, and falling back to the cleaned absolute path is what the command path
// does too.
func canonicalSandboxWorkspaceRoot(root string) string {
cleaned := filepath.Clean(strings.TrimSpace(root))
if cleaned == "" || cleaned == "." {
return ""
}
if !filepath.IsAbs(cleaned) {
if absolute, err := filepath.Abs(cleaned); err == nil {
cleaned = absolute
}
}
// EvalSymlinks fails outright when the LEAF does not exist, which is the
// normal case for a cache or runtime root that has not been created yet. A
// plain call therefore resolved an existing workspace while leaving a
// not-yet-created cache root unresolved, and the two were compared against
// each other — the containment check that decides whether the runtime tree
// must move out of the workspace then ran on /private/var/... versus
// /var/..., missed, and left the tree inside the workspace.
//
// Resolve the longest existing ancestor and re-append the rest, so a path
// normalizes the same way whether or not its final segments exist yet.
remainder := ""
current := cleaned
for {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
if remainder == "" {
return resolved
}
return filepath.Join(resolved, remainder)
}
parent := filepath.Dir(current)
if parent == current {
// Nothing along the path resolved; the cleaned absolute form is the
// best stable key available.
return cleaned
}
remainder = filepath.Join(filepath.Base(current), remainder)
current = parent
}
}
-76
View File
@@ -329,79 +329,3 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
}
cleanupLease.release()
}
// sandboxRuntimeRootFor compares the workspace root against the derived runtime
// root to decide whether to fall back to a private temp tree. Both sides
// therefore have to be the same spelling of the same path.
//
// Canonicalizing only the workspace root broke this on CI: the workspace
// resolved (/var to /private/var on macOS, an 8.3 short name to its long form
// on Windows) while the cache root kept its original spelling, so the
// containment check compared two different strings, the fallback never fired,
// and the runtime tree was placed inside the workspace it exists to stay out of.
//
// A symlink is the portable way to produce a spelling that only resolution
// reconciles — Clean cannot see through one. Windows refuses to create symlinks
// without privilege, so this skips there; the platforms that CI caught the bug
// on are the ones that run it.
func TestPrepareSandboxRuntimeNormalizesTheCacheRootBeforeComparingIt(t *testing.T) {
workspace := t.TempDir()
link := filepath.Join(t.TempDir(), "workspace-link")
if err := os.Symlink(workspace, link); err != nil {
t.Skipf("cannot create a symlink here: %v", err)
}
// The cache root reaches us spelled through the symlink; the workspace does
// not. Resolved, it is plainly inside the workspace and the fallback must
// fire. Unresolved, the two strings share no prefix and it does not.
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return filepath.Join(link, ".cache"), nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
if release != nil {
defer release()
}
// Resolve before comparing. Spelled through the link the runtime root shares
// no textual prefix with the workspace, so an unresolved comparison would
// call it "outside" while it sits physically inside — the test would pass
// against the very bug it exists for.
resolved := runtimeState.Root
if actual, err := filepath.EvalSymlinks(runtimeState.Root); err == nil {
resolved = actual
}
if pathWithinRoot(workspace, resolved) {
t.Fatalf("runtime root %q resolves to %q, inside workspace %q; the containment check did not see through the cache root's spelling", runtimeState.Root, resolved, workspace)
}
}
// A path whose final segments do not exist yet must still normalize the same
// way as one that does. This is the shape macOS CI hit: t.TempDir() sits under
// /var, a symlink to /private/var, and the cache root it derives has not been
// created when it is first normalized. Resolving only the workspace left the
// two sides of the containment check spelled differently.
func TestCanonicalSandboxWorkspaceRootResolvesThroughAMissingLeaf(t *testing.T) {
real := t.TempDir()
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink(real, link); err != nil {
t.Skipf("cannot create a symlink here: %v", err)
}
existing := canonicalSandboxWorkspaceRoot(link)
if existing != canonicalSandboxWorkspaceRoot(real) {
t.Fatalf("an existing symlinked dir did not resolve: %q vs %q", existing, canonicalSandboxWorkspaceRoot(real))
}
// The leaf, and its parent, do not exist.
missing := filepath.Join(link, ".cache", "zero")
got := canonicalSandboxWorkspaceRoot(missing)
want := filepath.Join(existing, ".cache", "zero")
if got != want {
t.Errorf("missing leaf normalized to %q, want %q — the ancestor was not resolved", got, want)
}
if !pathWithinRoot(existing, got) {
t.Errorf("%q should be inside %q once both are canonical", got, existing)
}
}
+25 -239
View File
@@ -19,17 +19,6 @@ type Scope struct {
workspaceRoot string
readRoots []string
extraRoots []string
// tempReads / tempWrites count how many LIVE temporary grants depend on a
// root, so one holder's cleanup cannot revoke another's access.
//
// Without them a temporary grant was add-then-remove with no notion of who
// still needed it: the second caller to ask for a root already present got a
// NO-OP undo, and the first caller's cleanup removed the root out from under
// it. Two read-only tools in the same parallel batch, both blocked on the
// same directory, is exactly that shape — and read-only tools are precisely
// the ones the batch runs concurrently.
tempReads map[string]int
tempWrites map[string]int
}
// NewScope builds a scope for workspaceRoot plus the given extra roots. The
@@ -92,88 +81,15 @@ func (s *Scope) Add(path string) (string, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// COVERED BY WHAT. extraRoots holds temporary write roots alongside
// permanent ones, so "something already covers this" was not the question
// worth asking: a permanent grant made over a path a temporary holder
// happened to cover recorded nothing, and vanished the moment that holder
// released. A session-scoped grant outliving the request that prompted it is
// the entire difference between Add and AddTemporaryWrite.
//
// Permanent coverage is looked for FIRST and across every root, because a
// path can be covered twice and the temporary cover must not decide the
// answer when a permanent one is also present.
if s.permanentWriteRootCoversLocked(root) {
return root, nil
}
for _, existing := range s.extraRoots {
// The same root, held temporarily: promote it in place. Its holders'
// releases become no-ops, which is what permanence means here.
if existing == root && s.temporaryWriteLocked(existing) {
delete(s.tempWrites, root)
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
return root, nil
}
}
// Either uncovered, or covered only by a BROADER temporary root that is
// going to be released. Recording the narrower root in its own right is what
// survives that release.
s.extraRoots = append(s.extraRoots, root)
return root, nil
}
// temporaryWriteLocked reports whether root is held by a temporary write grant
// rather than a session-scoped one. Callers must hold the lock.
func (s *Scope) temporaryWriteLocked(root string) bool {
_, temporary := s.tempWrites[root]
return temporary
}
// permanentWriteRootCoversLocked reports whether root sits under write authority
// that outlives every temporary holder: the workspace root, or a session-scoped
// grant. Coverage by a TEMPORARY write root deliberately does not count —
// it is going to be released, so nothing recorded on the strength of it
// survives. Callers must hold the lock.
//
// One helper rather than a copy per caller because Add, AddRead and
// AddTemporaryRead all turn on the same question, and three copies of "covered,
// but by what" is how one of them ends up answering it differently.
// permanentReadRootCoversLocked reports whether root is already readable by a
// grant that outlives every temporary holder.
//
// SLICE ORDER MUST NOT DECIDE THIS. The old check walked readRoots and stopped at
// the first entry covering the path, so a BROAD TEMPORARY root sitting earlier in
// the slice shadowed a narrower PERMANENT one later — and the caller was handed a
// reference on somebody else's grant when it needed no reference at all. Asking
// the whole question first, over both lists, makes the answer independent of
// insertion order.
//
// Write roots count: a grant that permits writing permits reading the same tree.
func (s *Scope) permanentReadRootCoversLocked(root string) bool {
if s.permanentWriteRootCoversLocked(root) {
return true
}
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) && !s.temporaryReadLocked(existing) {
return true
}
}
return false
}
func (s *Scope) permanentWriteRootCoversLocked(root string) bool {
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) && !s.temporaryWriteLocked(existing) {
return true
}
}
return false
}
// temporaryReadLocked is temporaryWriteLocked for read roots.
func (s *Scope) temporaryReadLocked(root string) bool {
_, temporary := s.tempReads[root]
return temporary
}
// AddRead grants read-only access under path. If the path is already covered by
// a writable root, no separate read root is stored.
func (s *Scope) AddRead(path string) (string, error) {
@@ -183,19 +99,11 @@ func (s *Scope) AddRead(path string) (string, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// Same rule as Add: coverage that is going to be released is not coverage a
// permanent grant can rely on, whichever side of the read/write boundary it
// sits on. A permanent read covered only by a temporary WRITE root died with
// that root just as surely.
if s.permanentWriteRootCoversLocked(root) {
return root, nil
}
if s.permanentReadRootCoversLocked(root) {
if s.writeRootCoversLocked(root) {
return root, nil
}
for _, existing := range s.readRoots {
if existing == root && s.temporaryReadLocked(existing) {
delete(s.tempReads, root)
if pathWithinRoot(existing, root) {
return root, nil
}
}
@@ -210,63 +118,16 @@ func (s *Scope) AddTemporaryRead(path string) (string, func(), error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// Genuinely permanent — the workspace root, or a session-scoped grant. It
// outlives this reader, so there is nothing to release.
if s.permanentWriteRootCoversLocked(root) {
if s.writeRootCoversLocked(root) {
return root, func() {}, nil
}
// A TEMPORARY write root covering this path is NOT borrowed, and this is the
// one place the distinction bites hardest.
//
// The dependency on that root is real — the write holder's cleanup must not
// revoke this reader, which is the defect the refcount exists to close,
// reached across the read/write boundary. But taking a reference on the
// WRITE root pays for the lifetime with the authority: the reference keeps
// the root in extraRoots, extraRoots is what Roots() feeds validate(), and
// validate() is WRITE authorization. So a reader outliving its writer went on
// writing anywhere under a write grant that had already ended — not merely
// under the path it asked to read.
//
// One reference cannot be both the lifetime and the capability. The lifetime
// is kept below, as a temporary READ root for the path this caller actually
// asked for. readRoots feeds ReadRoots(), which confers no write authority —
// validate() authorises writes from workspaceRoot plus extraRoots, and this
// path never enters that list. So the reader survives its writer with read
// authority and only read authority.
//
// That claim is the written justification for the whole design, so it has to
// stay exhaustive as the accessors change. It has already been wrong once, in
// both directions: an earlier draft said readRoots fed ReadRoots() and nothing
// else while ExtraReadRoots() also read it, and that accessor has since been
// removed as belonging to a different change.
if s.permanentReadRootCoversLocked(root) {
return root, func() {}, nil
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) {
return root, func() {}, nil
}
}
if s.tempReads == nil {
s.tempReads = map[string]int{}
}
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an EXACT
// match shares — a narrower request must not, which is the whole point below.
if _, held := s.tempReads[root]; held {
s.tempReads[root]++
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
}
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even under a broader temporary
// read that currently covers it.
//
// Borrowing the covering root's reference kept this reader alive by keeping
// the BROAD ROOT alive, and readRoots feeds validateRead and the native
// sandbox profile. So once the broad reader released, a caller that had asked
// to read one subdirectory was left able to read the whole tree its neighbour
// had asked for, siblings included. Reported by @jatmn, and it is the read
// twin of the write escalation fixed above: the refcount key decides not only
// WHEN an entry disappears but WHICH path stays authorised while it lives.
s.readRoots = append(s.readRoots, root)
if s.tempReads == nil {
s.tempReads = map[string]int{}
}
s.tempReads[root] = 1
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
return root, func() { s.removeReadRoot(root) }, nil
}
func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
@@ -276,107 +137,32 @@ func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// A PERMANENT grant is the only cover that needs no bookkeeping: it outlives
// every temporary holder, so there is nothing to keep alive and nothing to
// release.
if s.permanentWriteRootCoversLocked(root) {
if s.writeRootCoversLocked(root) {
return root, func() {}, nil
}
if s.tempWrites == nil {
s.tempWrites = map[string]int{}
}
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an exact
// match shares an entry — a NARROWER request must not, which is the whole
// point below.
if _, held := s.tempWrites[root]; held {
s.tempWrites[root]++
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
}
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even when a broader temporary
// grant currently covers it.
//
// Borrowing a reference on the covering root kept this holder alive, which
// was the point, but it kept the COVERING ROOT alive to do it — and
// extraRoots is what validate() authorises writes from. So once the broad
// holder released, a caller that had asked to write one subdirectory was
// left holding write authority over the whole tree its neighbour had asked
// for, including siblings it never named. Reported by CodeRabbit, and it is
// the write-side twin of the read-side escalation @jatmn found: one
// reference cannot be both a lifetime and a capability, whichever side of
// the read/write boundary it sits on.
//
// Recording the requested root separately gives each holder exactly the
// authority it asked for and a lifetime of its own. The nesting costs an
// extra entry in extraRoots while both are live, which is redundant but not
// wrong — the broader root already permits everything the narrower one does.
s.extraRoots = append(s.extraRoots, root)
s.tempWrites[root] = 1
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
return root, func() { s.removeWriteRoot(root) }, nil
}
// releaseTemporaryRead drops ONE holder's reference and removes the root only
// when the last one is gone.
//
// The count alone cannot make this safe, and an earlier comment here claimed it
// could — that each undo is called exactly once, and flooring at zero handled
// the rest. Flooring stops the count going negative; it does not stop one
// holder's second call consuming a DIFFERENT holder's reference. With two
// readers, calling the first's undo twice took the count 2 -> 1 -> 0 and removed
// a root the second was still using. Idempotency belongs to the closure, which
// is the thing that knows whose reference it is, so every undo handed out is
// wrapped in oncePerHolder.
func (s *Scope) releaseTemporaryRead(root string) {
func (s *Scope) writeRootCoversLocked(root string) bool {
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
return true
}
}
return false
}
func (s *Scope) removeReadRoot(root string) {
s.mu.Lock()
remaining, tracked := s.tempReads[root]
if !tracked {
s.mu.Unlock()
return
}
remaining--
if remaining > 0 {
s.tempReads[root] = remaining
s.mu.Unlock()
return
}
// Both mutations under ONE hold. Dropping the lock between them opened a
// window where the root was still in readRoots but no longer in tempReads:
// a concurrent AddTemporaryRead landing there reads it as a PERMANENT root,
// hands its caller a no-op undo, and then this call strips the root — so
// that caller believes it holds access it has already silently lost.
delete(s.tempReads, root)
defer s.mu.Unlock()
s.readRoots = removeScopeRoot(s.readRoots, root)
s.mu.Unlock()
}
// releaseTemporaryWrite is releaseTemporaryRead for write roots. Two functions
// rather than one generic helper because they guard different slices and the
// generic version would take the slice by name — which is how the wrong one
// gets passed.
func (s *Scope) releaseTemporaryWrite(root string) {
func (s *Scope) removeWriteRoot(root string) {
s.mu.Lock()
remaining, tracked := s.tempWrites[root]
if !tracked {
s.mu.Unlock()
return
}
remaining--
if remaining > 0 {
s.tempWrites[root] = remaining
s.mu.Unlock()
return
}
// Same single-hold rule as releaseTemporaryRead above.
delete(s.tempWrites, root)
defer s.mu.Unlock()
s.extraRoots = removeScopeRoot(s.extraRoots, root)
s.mu.Unlock()
}
// oncePerHolder makes one holder's undo safe to call more than once. The
// reference it drops is that holder's own, so a second call must do nothing
// rather than reach into the shared count and take somebody else's.
func oncePerHolder(release func()) func() {
var once sync.Once
return func() { once.Do(release) }
}
func removeScopeRoot(roots []string, root string) []string {
-56
View File
@@ -1,56 +0,0 @@
package sandbox
import (
"path/filepath"
"testing"
)
// THE ANTI-ESCALATION PROPERTY. A read grant (AddRead, where --add-read-dir lands)
// is READABLE but NOT WRITABLE. This is what makes propagating a parent's
// request_permissions read grant to a child safe: the child can audit the granted
// path but cannot modify it. Emitting the grant as a WRITE root (--add-dir) would
// break exactly this.
func TestAReadGrantIsReadableButNotWritable(t *testing.T) {
scope, granted := grantOutsideDefaults(t)
root, err := scope.AddRead(granted)
if err != nil {
t.Fatalf("AddRead: %v", err)
}
target := filepath.Join(root, "audit-me.go")
if block := scope.validateRead(target); block != nil {
t.Fatalf("a read grant did not allow READING %q: %v — the audit would fail 'outside the workspace'", target, block)
}
if block := scope.validate(target); block == nil {
t.Fatalf("a read grant ALLOWED WRITING %q — a read grant escalated to write", target)
}
}
// grantOutsideDefaults returns a scope and a directory that scope does not
// already cover, both under test-owned storage.
//
// THE CAUSE, NOT THE SYMPTOM. NewScope seeds the system temporary directory as a
// permanent write root, so a plain t.TempDir() grant is writable before any
// grant exists and the anti-escalation assertions below prove nothing. The first
// fix placed the fixture under os.UserHomeDir() to find ground the defaults do
// not own. That worked and bought two problems: a hermetic runner may expose
// HOME read-only, so these tests failed at MkdirTemp before asserting anything,
// and on an ordinary machine they wrote into the developer's real home and left
// zero-scope-* debris whenever a run was interrupted. Reported by @jatmn.
//
// Building the Scope directly removes the seeded defaults, which is what made
// HOME necessary in the first place. t.TempDir() is then outside by
// construction, the fixture is hermetic, and nothing touches HOME.
func grantOutsideDefaults(t *testing.T) (*Scope, string) {
t.Helper()
workspace := resolvedFixturePath(t, t.TempDir())
granted := resolvedFixturePath(t, t.TempDir())
scope := &Scope{workspaceRoot: workspace}
// PROVED AGAINST THIS SCOPE. The point of the helper is that the grant is
// what changes access, so the target has to begin unauthorised — asserted
// here once rather than trusted in every caller.
if scope.validate(filepath.Join(granted, "probe.txt")) == nil {
t.Fatalf("%s is writable before any grant exists; the assertions below would prove nothing", granted)
}
return scope, granted
}
@@ -1,595 +0,0 @@
package sandbox
import (
"os"
"path/filepath"
"slices"
"sync"
"testing"
)
// scopeOutsideDefaults builds a Scope directly instead of through NewScope.
//
// NewScope adds the system temp directory as a write root, so a target under
// t.TempDir() is already covered and AddTemporaryRead returns before it ever
// touches the refcount. A test built that way exercises none of this and passes
// against the bug — which is how the first version of this test passed.
func scopeOutsideDefaults(t *testing.T) (*Scope, string) {
t.Helper()
workspace := t.TempDir()
target := filepath.Join(t.TempDir(), "shared")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("mkdir target: %v", err)
}
// Built directly, NOT via NewScope: real directories are needed because
// normalizeScopeRoot requires them to exist, but NewScope's default temp
// write root would then cover the target and short-circuit the path.
return &Scope{workspaceRoot: workspace}, target
}
// The refcount and the root list have to move together.
//
// Release used to delete the refcount, drop the lock, then strip the root in a
// second acquisition. In that window the root was still in readRoots but no
// longer in tempReads, so a concurrent AddTemporaryRead read it as a PERMANENT
// root and handed its caller a no-op undo — then the release stripped it and
// that caller silently lost access it believed it held.
func TestReleasingATemporaryReadCannotStripALiveGrant(t *testing.T) {
scope, target := scopeOutsideDefaults(t)
// First holder establishes the root.
first, releaseFirst, err := scope.AddTemporaryRead(target)
if err != nil {
t.Fatalf("AddTemporaryRead: %v", err)
}
if len(scope.tempReads) == 0 {
t.Fatal("precondition: the grant should be refcounted, not covered by a default root")
}
// Second holder takes a reference on the same root.
second, releaseSecond, err := scope.AddTemporaryRead(target)
if err != nil {
t.Fatalf("AddTemporaryRead (second): %v", err)
}
// The first holder leaving must not revoke the second's access.
releaseFirst()
if block := scope.validateRead(second); block != nil {
t.Fatalf("the second holder lost its grant when the first released: %v", block)
}
// Only the last release retires the root.
releaseSecond()
if block := scope.validateRead(first); block == nil {
t.Error("the root outlived its last holder")
}
}
// Under contention the same invariant has to hold: while a grant is live, its
// root is readable.
func TestTemporaryReadGrantsSurviveConcurrentReleases(t *testing.T) {
scope, target := scopeOutsideDefaults(t)
var wg sync.WaitGroup
var mu sync.Mutex
var failures []string
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
granted, undo, err := scope.AddTemporaryRead(target)
if err != nil {
mu.Lock()
failures = append(failures, "AddTemporaryRead: "+err.Error())
mu.Unlock()
return
}
if block := scope.validateRead(granted); block != nil {
mu.Lock()
failures = append(failures, "root not readable while a grant was live")
mu.Unlock()
}
undo()
}
}()
}
wg.Wait()
if len(failures) > 0 {
t.Fatalf("%d failures, first: %s", len(failures), failures[0])
}
}
// A read covered by a TEMPORARY WRITE root must take a reference too.
//
// permanentWriteRootCoversLocked scans extraRoots, and AddTemporaryWrite appends
// temporary write roots there — so "covered by a write root" does not mean
// "covered permanently". Without a reference the write holder's cleanup silently
// revoked the reader's access: the same defect the refcount closes, reached
// across the read/write boundary instead of within one side of it.
func TestAReadCoveredByATemporaryWriteSurvivesItsRelease(t *testing.T) {
workspace := t.TempDir()
outer := filepath.Join(t.TempDir(), "outer")
inner := filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
// Built directly for the same reason scopeOutsideDefaults is: NewScope's
// default temp write root would cover the fixture and short-circuit this.
scope := &Scope{workspaceRoot: workspace}
_, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
readRoot, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
covered := func() bool {
for _, root := range scope.ReadRoots() {
if pathWithinRoot(root, readRoot) {
return true
}
}
return false
}
if !covered() {
t.Fatal("the reader did not hold access before any release")
}
// The WRITE holder finishes first; the reader has not released.
releaseWrite()
if !covered() {
t.Errorf("the write holder's cleanup revoked a live read grant on %s", readRoot)
}
// And once the reader releases too, the root is genuinely gone.
releaseRead()
if covered() {
t.Errorf("the root outlived its last holder: %v", scope.ReadRoots())
}
}
func hasExtraRoot(scope *Scope, root string) bool {
for _, existing := range writeRootsBeyondWorkspace(scope) {
if existing == root {
return true
}
}
return false
}
// A PERMANENT GRANT MUST OUTLIVE THE TEMPORARY ONE IT LANDED ON TOP OF.
//
// extraRoots holds temporary write roots alongside permanent ones, so Add asked
// only "is this already covered" — and a session-scoped grant made while a
// temporary holder happened to cover the path recorded nothing, then vanished
// when that holder released. Outliving the request that prompted it is the
// entire difference between Add and AddTemporaryWrite.
func TestAPermanentGrantSurvivesTheTemporaryOneItCovered(t *testing.T) {
t.Run("write, same root", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseTemp, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.Add(outside); err != nil {
t.Fatal(err)
}
releaseTemp()
if !hasExtraRoot(scope, outside) {
t.Error("the temporary holder's release revoked a permanent write grant")
}
})
t.Run("read, same root", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseTemp, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
releaseTemp()
if !hasReadRoot(scope, outside) {
t.Error("the temporary holder's release revoked a permanent read grant")
}
})
// The nested shape: a BROAD temporary root covering a NARROW permanent one.
// Promoting in place cannot help here — the narrow root has to be recorded
// in its own right, or it goes when the broad one does.
t.Run("broad temporary over narrow permanent", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
inner := filepath.Join(outside, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
_, releaseBroad, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.Add(inner); err != nil {
t.Fatal(err)
}
releaseBroad()
covered := false
for _, existing := range writeRootsBeyondWorkspace(scope) {
if pathWithinRoot(existing, inner) {
covered = true
}
}
if !covered {
t.Error("a narrower permanent grant died with the broader temporary root it sat under")
}
})
// THE READ MIRROR OF BOTH SHAPES ABOVE. AddRead makes two claims that the
// subtests so far only made for Add: the loop over extraRoots skips coverage
// that is a temporary WRITE, and the loop over readRoots skips coverage that
// is a temporary READ. Each claim is a separate branch, and neither was
// exercised from the read side.
t.Run("permanent read under a temporary write", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseWrite, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
releaseWrite()
if !hasReadRoot(scope, outside) {
t.Error("a temporary write holder's release revoked a permanent read grant")
}
})
t.Run("broad temporary read over narrow permanent read", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
inner := filepath.Join(outside, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
_, releaseBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(inner); err != nil {
t.Fatal(err)
}
releaseBroad()
if block := scope.validateRead(filepath.Join(inner, "audit-me.go")); block != nil {
t.Errorf("a narrower permanent read died with the broader temporary read it sat under: %v", block)
}
})
}
// ONE HOLDER'S UNDO DROPS ONE HOLDER'S REFERENCE, HOWEVER OFTEN IT IS CALLED.
//
// The count flooring at zero stops it going negative; it does not stop a
// holder's second call consuming somebody else's reference. With two readers,
// calling the first's undo twice took the count 2 -> 1 -> 0 and removed a root
// the second was still using — the exact revocation this refcount exists to
// prevent, reached through a duplicate call rather than a sibling's cleanup.
// Deferred cleanups in a retry path are how a real caller does this by accident.
func TestOneHoldersUndoIsIdempotent(t *testing.T) {
scope, workspace, outside := scopeOutsideRoots(t)
_, undoFirst, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoSecond, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undoFirst()
undoFirst()
undoFirst()
if !hasReadRoot(scope, outside) {
t.Fatal("repeated calls to one holder's undo revoked a live holder's access")
}
undoSecond()
if hasReadRoot(scope, outside) {
t.Error("the root outlived its last holder")
}
// The write side takes the same rule. Built directly for the same reason the
// helper is: NewScope would seed the system temp directory as a permanent
// write root, which already covers this fixture and makes the grant vacuous.
writeScope := &Scope{workspaceRoot: workspace}
_, undoWriteFirst, err := writeScope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
_, undoWriteSecond, err := writeScope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
undoWriteFirst()
undoWriteFirst()
if !hasExtraRoot(writeScope, outside) {
t.Fatal("repeated calls to one write holder's undo revoked a live holder's access")
}
undoWriteSecond()
if hasExtraRoot(writeScope, outside) {
t.Error("the write root outlived its last holder")
}
}
// temporaryWriteFixture builds a nested pair of real directories and a scope
// that covers neither: an OUTER directory a temporary write grant will take,
// and an INNER one below it that a temporary read grant will ask for.
//
// Built directly rather than through NewScope, for the reason
// scopeOutsideDefaults documents: NewScope seeds the system temp directory as a
// PERMANENT write root, which would cover the whole fixture and send every
// branch under test down the "genuinely permanent" path instead. Both paths are
// returned symlink-resolved because normalizeScopeRoot resolves what it stores,
// and on macOS the fixture's /var spelling is a symlink to /private/var.
func temporaryWriteFixture(t *testing.T) (scope *Scope, workspace, outer, inner string) {
t.Helper()
outer = filepath.Join(t.TempDir(), "outer")
inner = filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
workspace = resolvedFixturePath(t, t.TempDir())
return &Scope{workspaceRoot: workspace}, workspace, resolvedFixturePath(t, outer), resolvedFixturePath(t, inner)
}
func resolvedFixturePath(t *testing.T, path string) string {
t.Helper()
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("resolve fixture %s: %v", path, err)
}
return resolved
}
// A READER MUST NOT INHERIT ITS WRITER'S AUTHORITY.
//
// The reader's dependency on a covering TEMPORARY WRITE root was recorded as a
// reference on that root — one reference standing for both the lifetime and the
// capability. Release the WRITER first and the reference kept the path in
// extraRoots, which is the list Roots() feeds validate(), so the surviving
// read-only holder could WRITE anywhere below a write grant that had already
// ended — including paths it never asked to read.
//
// This is TestAReadGrantIsReadableButNotWritable's property for temporary
// grants: read authority stays read authority, whichever holder leaves first.
func TestAReaderOutlivingItsWriterKeepsReadAuthorityOnly(t *testing.T) {
t.Run("the writer releases first", func(t *testing.T) {
scope, workspace, outer, inner := temporaryWriteFixture(t)
writeRoot, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
if writeRoot != outer {
t.Fatalf("temporary write granted %q, want %q", writeRoot, outer)
}
readRoot, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
if readRoot != inner {
t.Fatalf("temporary read granted %q, want %q", readRoot, inner)
}
// The reader holds a read root of its own and NO reference on the write
// root. Both halves matter: the first is what survives the writer, the
// second is what stops the write root surviving with it.
//
// Errorf, not Fatalf: these say WHY the behavior below breaks, and a
// bookkeeping check that aborts the run hides the behavior it explains.
scope.mu.RLock()
writeHolders := scope.tempWrites[outer]
readHolders := scope.tempReads[inner]
scope.mu.RUnlock()
if writeHolders != 1 {
t.Errorf("tempWrites[%s] = %d, want 1: the reader took a reference on the WRITE root", outer, writeHolders)
}
if readHolders != 1 {
t.Errorf("tempReads[%s] = %d, want 1: the reader kept no read root of its own", inner, readHolders)
}
// The WRITE holder finishes; only the read-only holder is left.
releaseWrite()
if got := writeRootsBeyondWorkspace(scope); len(got) != 0 {
t.Errorf("ExtraRoots() = %v, want none: the reader held the write root open", got)
}
if got, want := scope.ReadRoots(), []string{workspace, inner}; !slices.Equal(got, want) {
t.Errorf("ReadRoots() = %v, want %v", got, want)
}
target := filepath.Join(inner, "audit-me.go")
if block := scope.validateRead(target); block != nil {
t.Errorf("the writer's cleanup revoked a live read grant on %q: %v", target, block)
}
block := scope.validate(target)
if block == nil {
t.Fatalf("a read-only holder may still WRITE %q after the write grant ended", target)
}
if block.Code != BlockOutsideWorkspace {
t.Errorf("write block code = %q, want %q", block.Code, BlockOutsideWorkspace)
}
if block.Path != target {
t.Errorf("write block path = %q, want %q", block.Path, target)
}
// The escalation was never confined to the path the reader asked for —
// a borrowed reference carries the whole covering root — so a sibling
// the reader never named has to be denied too.
sibling := filepath.Join(outer, "sibling.txt")
if block := scope.validate(sibling); block == nil {
t.Errorf("a read-only holder of %q may still WRITE %q, which it never asked to read", inner, sibling)
}
releaseRead()
if got, want := scope.ReadRoots(), []string{workspace}; !slices.Equal(got, want) {
t.Errorf("ReadRoots() = %v, want %v: the read root outlived its last holder", got, want)
}
})
// A CONTROL, NOT COVERAGE FOR THE FINDING. This subtest passes on the
// unfixed tree too — the escalation needs the WRITER to go first, which the
// subtest above exercises. It earns its place by pinning that the opposite
// release order was not broken while fixing the dangerous one, and it is
// labelled so nobody counts it as evidence the finding is closed.
t.Run("the reader releases first (control: passes unfixed)", func(t *testing.T) {
scope, _, outer, inner := temporaryWriteFixture(t)
_, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
_, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
// The other order, which the fix must not break: the writer keeps FULL
// authority for as long as it holds the grant.
releaseRead()
buildLog := filepath.Join(outer, "build.log")
if block := scope.validate(buildLog); block != nil {
t.Fatalf("the reader's cleanup revoked the live write grant on %q: %v", buildLog, block)
}
if got, want := writeRootsBeyondWorkspace(scope), []string{outer}; !slices.Equal(got, want) {
t.Errorf("ExtraRoots() = %v, want %v", got, want)
}
releaseWrite()
if block := scope.validate(buildLog); block == nil {
t.Errorf("the write root %q outlived its only holder", outer)
}
if block := scope.validateRead(filepath.Join(inner, "audit-me.go")); block == nil {
t.Errorf("the read root %q outlived its only holder", inner)
}
})
}
// A NARROWER WRITE HOLDER MUST NOT INHERIT ITS NEIGHBOUR'S TREE.
//
// The write-side twin of the read-side escalation above, and the same root
// cause: a nested holder used to take a reference on the BROADER temporary root
// that happened to cover it. That kept the nested holder alive, which was the
// point, but it kept the covering root alive to do it — and extraRoots is what
// validate() authorises writes from. Once the broad holder released, a caller
// that had asked for one subdirectory held write authority over the whole tree,
// siblings included. Reported by CodeRabbit.
func TestANestedWriteHolderKeepsOnlyItsOwnSubtree(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
outer := filepath.Join(outsideBase, "outer")
inner := filepath.Join(outer, "inner")
sibling := filepath.Join(outer, "sibling")
for _, dir := range []string{outer, inner, sibling} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, releaseOuter, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write on the broad root: %v", err)
}
_, releaseInner, err := scope.AddTemporaryWrite(inner)
if err != nil {
t.Fatalf("temporary write on the nested root: %v", err)
}
// THE BROAD HOLDER GOES FIRST — the nested holder outliving it is what
// exposes whose authority it is actually holding.
releaseOuter()
if block := scope.validate(filepath.Join(inner, "mine.txt")); block != nil {
t.Errorf("the nested holder lost the subtree it asked for: %v", block.Reason)
}
if block := scope.validate(filepath.Join(sibling, "not-mine.txt")); block == nil {
t.Error("the nested holder can write a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
}
if block := scope.validate(filepath.Join(outer, "not-mine.txt")); block == nil {
t.Error("the nested holder can write the covering root itself after its holder released")
}
releaseInner()
if block := scope.validate(filepath.Join(inner, "mine.txt")); block == nil {
t.Error("the nested root outlived its only holder")
}
}
// The opposite order, as a control: the nested holder releasing first must not
// disturb the broad holder that is still live.
func TestANestedWriteHolderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
outer := filepath.Join(outsideBase, "outer")
inner := filepath.Join(outer, "inner")
for _, dir := range []string{outer, inner} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, releaseOuter, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatal(err)
}
_, releaseInner, err := scope.AddTemporaryWrite(inner)
if err != nil {
t.Fatal(err)
}
releaseInner()
if block := scope.validate(filepath.Join(inner, "still-ok.txt")); block != nil {
t.Errorf("the nested holder's release revoked the broad holder's coverage: %v", block.Reason)
}
releaseOuter()
if block := scope.validate(filepath.Join(inner, "gone.txt")); block == nil {
t.Error("the tree outlived every holder")
}
}
// TWO HOLDERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares —
// a narrower request must not, which is what the escalation test above pins.
func TestTwoHoldersOfTheSameWriteRootShareOneEntry(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
target := filepath.Join(outsideBase, "shared")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatal(err)
}
_, releaseFirst, err := scope.AddTemporaryWrite(target)
if err != nil {
t.Fatal(err)
}
_, releaseSecond, err := scope.AddTemporaryWrite(target)
if err != nil {
t.Fatal(err)
}
releaseFirst()
if block := scope.validate(filepath.Join(target, "still-held.txt")); block != nil {
t.Errorf("one holder's release revoked the other's grant on the same root: %v", block.Reason)
}
releaseSecond()
if block := scope.validate(filepath.Join(target, "gone.txt")); block == nil {
t.Error("the shared root outlived both holders")
}
}
// writeRootsBeyondWorkspace is Roots() without the workspace entry.
//
// These tests need to see which BEYOND-WORKSPACE write roots a scope currently
// holds. There was an ExtraRoots() accessor that answered exactly this, but it
// had no production caller — it belongs to #829's child-grant propagation, whose
// consumer side is not on this branch, and @jatmn was right that carrying it here
// leaves unrelated production surface in a PR scoped to refcounting. The
// inspection is a test concern, so it lives in the tests.
func writeRootsBeyondWorkspace(scope *Scope) []string {
all := scope.Roots()
if len(all) == 0 {
return nil
}
return append([]string(nil), all[1:]...)
}
-366
View File
@@ -1,366 +0,0 @@
package sandbox
import (
"os"
"path/filepath"
"sync"
"testing"
)
// scopeOutsideRoots builds a workspace and an unrelated directory that no
// default write root already covers.
//
// NOT t.TempDir(). /tmp and $TMPDIR are default write roots, so a temporary
// grant for a path under one is a no-op — every assertion here would pass
// against code that grants nothing at all. That is RULES §2.3 in its exact
// form, and it caught the first version of this test.
// scopeOutsideRoots builds a workspace and a directory outside it, both under
// test-owned storage, and returns a Scope that treats the second as genuinely
// outside.
//
// THE FIXTURE PROBLEM AND ITS ACTUAL CAUSE. NewScope seeds the system temporary
// directory as a permanent write root, so an ordinary t.TempDir() grant is
// covered before any temporary grant exists and every assertion built on it is
// vacuous. The first fix for that reached into os.UserHomeDir() to find ground
// the defaults do not already own — which worked, and bought two new problems:
// a hermetic runner may expose HOME read-only, so the tests failed at MkdirTemp
// before asserting anything; and on an ordinary machine they wrote fixtures into
// the developer's real home and left zeromax-scope-* debris behind whenever a
// run was interrupted. Reported by @jatmn.
//
// The cause is the seeded defaults, not the location, so the Scope is built
// DIRECTLY here — the same pattern scopeOutsideDefaults and temporaryWriteFixture
// already use. With no defaults seeded, t.TempDir() is outside by construction,
// the fixture is hermetic, and nothing touches HOME.
//
// Paths are symlink-resolved because the scope stores resolved roots and macOS
// hands out /var/... temp directories that resolve to /private/var/...; an
// unresolved fixture path compares unequal to the root the scope just recorded.
func scopeOutsideRoots(t *testing.T) (scope *Scope, workspace string, outside string) {
t.Helper()
workspace = resolvedFixturePath(t, t.TempDir())
outside = filepath.Join(resolvedFixturePath(t, t.TempDir()), "outside")
// FATAL, NOT SKIP. Every caller of this helper asserts an AUTHORIZATION
// boundary, so a skipped fixture means that assertion never ran and the build
// reported green anyway. t.TempDir() already failed the test if it could not
// provide storage, so a subdirectory failing under it is a broken machine.
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatalf("cannot prepare %s: %v", outside, err)
}
scope = &Scope{workspaceRoot: workspace}
// PROVED AGAINST THIS SCOPE, not against production's default list. The point
// of the helper is that a grant here changes something, and the only thing
// that can answer that is the scope the test will actually use. Checking
// defaultTempWriteRoots() instead asks about a scope nobody builds here — and
// says "prove nothing" for a t.TempDir() path that this scope, seeded with no
// defaults, genuinely does not cover.
//
// Asserting the NEGATIVE up front is also what keeps every caller honest: it
// establishes that the target begins unauthorised, so a later success is the
// grant's doing and not the fixture's placement.
if scope.validate(filepath.Join(outside, "probe.txt")) == nil {
t.Fatalf("%s is writable before any grant exists, so a temporary grant here would prove nothing", outside)
}
if scope.validateRead(filepath.Join(outside, "probe.txt")) == nil {
t.Fatalf("%s is readable before any grant exists, so a temporary read grant here would prove nothing", outside)
}
return scope, workspace, outside
}
func hasReadRoot(scope *Scope, root string) bool {
for _, existing := range scope.ReadRoots() {
if existing == root {
return true
}
}
return false
}
// THE DEFECT: one holder's cleanup revoked another's access. Two read-only
// tools in the same parallel batch, both blocked on the same directory, is
// exactly this — and read-only tools are the ones the batch runs concurrently.
func TestATemporaryReadSurvivesASiblingsCleanup(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
// The probe that the fix must make unnecessary: without it this reads
// true -> false.
if hasReadRoot(scope, outside) {
t.Fatal("the outside root is already covered; this test proves nothing")
}
_, undoA, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoB, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if !hasReadRoot(scope, outside) {
t.Fatal("the grant did not take effect")
}
undoA()
if !hasReadRoot(scope, outside) {
t.Fatal("A's cleanup revoked the root while B still held a grant")
}
undoB()
if hasReadRoot(scope, outside) {
t.Fatal("the root outlived its last holder")
}
}
// A NARROWER REQUEST KEEPS ITS OWN SUBTREE AND NOTHING WIDER.
//
// THIS TEST USED TO ASSERT THE OPPOSITE, and the earlier version is worth
// recording because it is how the escalation survived a review round. It was
// written to prove a LIFETIME property — the broad holder's cleanup must not
// revoke a live narrow holder — and it proved that by asserting the BROAD root
// was still present afterwards. That is the defect stated as a requirement: the
// narrow holder was keeping its neighbour's whole tree alive in order to keep
// itself alive, so the surviving reader could read siblings it never asked for.
//
// Lifetime and capability are two properties and one reference cannot carry
// both. The assertions below name them separately: the requested path stays
// readable while its holder lives, AND a sibling under the released broad root
// is denied as soon as the holder that authorised it exits. Reported by @jatmn.
func TestANarrowerReaderKeepsOnlyItsOwnSubtree(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
sibling := filepath.Join(outside, "sibling")
for _, dir := range []string{nested, sibling} {
// FATAL, NOT SKIP. These directories are the fixture, not a precondition
// the environment might reasonably withhold — skipping on them reports a
// broken setup as a passing run.
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoNarrow, err := scope.AddTemporaryRead(nested)
if err != nil {
t.Fatal(err)
}
// THE BROAD HOLDER GOES FIRST. The narrow holder outliving it is what exposes
// whose authority it is actually holding.
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
t.Errorf("the broad holder's cleanup revoked the subtree the narrow holder asked for: %v", block.Reason)
}
if block := scope.validateRead(filepath.Join(sibling, "not-mine.txt")); block == nil {
t.Error("the narrow reader can read a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
}
if block := scope.validateRead(filepath.Join(outside, "not-mine.txt")); block == nil {
t.Error("the narrow reader can read the covering root itself after its holder released")
}
undoNarrow()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block == nil {
t.Error("the nested root outlived its only holder")
}
}
// The opposite order, as a control: the narrow reader releasing first must not
// disturb the broad reader that is still live.
func TestANarrowerReaderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoNarrow, err := scope.AddTemporaryRead(nested)
if err != nil {
t.Fatal(err)
}
undoNarrow()
if block := scope.validateRead(filepath.Join(nested, "still-ok.txt")); block != nil {
t.Errorf("the narrow holder's release revoked the broad holder's coverage: %v", block.Reason)
}
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "gone.txt")); block == nil {
t.Error("the tree outlived every holder")
}
}
// TWO READERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares; a
// narrower one never does, which is what the escalation test above pins.
func TestTwoReadersOfTheSameRootShareOneEntry(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, undoFirst, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoSecond, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undoFirst()
if block := scope.validateRead(filepath.Join(outside, "still-held.txt")); block != nil {
t.Errorf("one reader's release revoked the other's grant on the same root: %v", block.Reason)
}
undoSecond()
if block := scope.validateRead(filepath.Join(outside, "gone.txt")); block == nil {
t.Error("the shared root outlived both holders")
}
}
// A NARROW PERMANENT READ IS NOT BORROWED FROM A BROAD TEMPORARY ONE. The old
// scan walked readRoots in order and stopped at the first entry covering the
// path, so a broad TEMPORARY root earlier in the slice shadowed a narrower
// PERMANENT one later — and the caller took a reference it did not need on a
// grant that was about to end.
func TestAPermanentReadIsNotShadowedByABroadTemporaryOne(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
// The broad TEMPORARY grant is taken first, so it sits earlier in readRoots.
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(nested); err != nil {
t.Fatal(err)
}
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
t.Errorf("a PERMANENT read grant died with a broad temporary one that merely preceded it: %v", block.Reason)
}
}
// A PERMANENT root is not refcounted and must never be removed by a temporary
// holder's cleanup — the undo for a request it already covers is genuinely
// nothing.
func TestATemporaryGrantNeverRevokesAPermanentRoot(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
_, undo, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undo()
if !hasReadRoot(scope, outside) {
t.Fatal("a temporary holder's cleanup removed a permanent read root")
}
}
// The same rule for WRITE roots, or a write grant keeps the bug reads no longer
// have.
func TestATemporaryWriteSurvivesASiblingsCleanup(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
covered := func() bool {
for _, existing := range scope.Roots() {
if existing == outside {
return true
}
}
return false
}
_, undoA, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
_, undoB, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if !covered() {
t.Fatal("the write grant did not take effect")
}
undoA()
if !covered() {
t.Fatal("A's cleanup revoked the write root while B still held a grant")
}
undoB()
if covered() {
t.Fatal("the write root outlived its last holder")
}
}
// UNDER REAL CONCURRENCY, which is the shape the parallel tool batch produces:
// eight holders taking and releasing the same root in overlapping windows. The
// root must be present the whole time at least one holder has it, and gone once
// the last releases.
func TestConcurrentHoldersOfOneRoot(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
const holders = 8
var wg sync.WaitGroup
start := make(chan struct{})
release := make(chan struct{})
// TWO PER HOLDER, because each one can report at both hasReadRoot checks. At
// one slot per holder the channel fills after eight reports, the ninth send
// blocks forever, wg.Done() is never reached and wg.Wait() hangs — so the
// test would DEADLOCK rather than fail in exactly the case it exists to
// catch, since nothing drains failures until after the wait.
failures := make(chan string, 2*holders)
// Signalled by EVERY holder once its own AddTemporaryRead has returned,
// failure included: the gate below waits for all of them, so a holder that
// returned early without signalling would hang this test rather than fail it.
acquired := make(chan struct{}, holders)
for i := 0; i < holders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, undo, err := scope.AddTemporaryRead(outside)
acquired <- struct{}{}
if err != nil {
failures <- err.Error()
return
}
// Every holder must SEE its own grant for as long as it holds it.
if !hasReadRoot(scope, outside) {
failures <- "a holder could not see the root it had just been granted"
}
<-release
if !hasReadRoot(scope, outside) {
failures <- "a holder lost the root while still holding it"
}
undo()
}()
}
close(start)
// EVERY holder, not the first one. Spinning on hasReadRoot only waited for
// somebody to hold the root, and the spin is tight enough to win that race
// against the goroutines still being scheduled: measured over 200 runs, 194
// of them peaked at a single simultaneous holder and none ever reached
// eight. A test named for concurrent holders was testing one holder at a
// time, and would have passed against an implementation with no refcount at
// all. Counting the acquisitions makes the overlap real instead of hoped for.
for i := 0; i < holders; i++ {
<-acquired
}
close(release)
wg.Wait()
close(failures)
for failure := range failures {
t.Fatal(failure)
}
if hasReadRoot(scope, outside) {
t.Fatal("the root outlived every holder")
}
// AND THE BOOKKEEPING IS EMPTY, not merely invisible. hasReadRoot answers
// what a caller can see; a refcount entry left behind at zero is not visible
// that way and would still leak, one map entry per acquire/release cycle.
scope.mu.Lock()
leftover, stillCounted := scope.tempReads[outside]
scope.mu.Unlock()
if stillCounted {
t.Fatalf("the refcount table kept an entry for %q after every holder released: %v", outside, leftover)
}
}
+5 -169
View File
@@ -2,77 +2,16 @@ package sandbox
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
// isWindowsVolumeRoot reports whether a cleaned path is the top of a volume,
// with nothing above it: `C:\`, a bare separator, or a UNC share root.
//
// Detected structurally rather than by pattern matching drive letters, because
// filepath.Dir of a root is that same root and of anything else is strictly
// shorter. That holds for drive-qualified paths, for the separator alone, and
// for UNC roots, on either build host.
func isWindowsVolumeRoot(path string) bool {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" || cleaned == "." {
return false
}
return filepath.Dir(cleaned) == cleaned
}
// validateWindowsACLComponent rejects anything that is not a single path
// component.
//
// This is load-bearing in two places, which is why it lives in the portable file
// rather than beside either of them.
//
// At apply time NtCreateFile happily resolves a RELATIVE name containing
// separators, and it resolves it the ordinary way, so an intermediate junction
// inside that name is followed and the object lands outside the pinned parent.
// A name with a separator reopens exactly the hole the parent handle exists to
// close.
//
// At plan time the same shape escapes the write root: a name is joined onto the
// root to place a deny ACE, so ".." or a separator puts that ACE on a directory
// outside the workspace entirely.
//
// The separators are checked explicitly rather than via filepath.Base, because
// these are Windows paths whatever the build host is, and on Linux
// filepath.Base leaves a backslash-joined name untouched and would wave it
// through. A colon is rejected too: it names an alternate data stream or a
// drive, neither of which is a child.
func validateWindowsACLComponent(name string) error {
switch {
case name == "":
return errors.New("windows ACL path component is empty")
case name == "." || name == "..":
return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name)
case strings.ContainsAny(name, `\/`):
return fmt.Errorf("windows ACL path component %q contains a separator, so it would resolve through intermediate directories instead of staying a child", name)
case strings.Contains(name, ":"):
return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name)
}
return nil
}
type WindowsACLAction string
const (
WindowsACLAllowWrite WindowsACLAction = "allow-write"
WindowsACLDenyRead WindowsACLAction = "deny-read"
WindowsACLDenyWrite WindowsACLAction = "deny-write"
// WindowsACLDenyDelete denies removing or renaming the object it names,
// WITHOUT denying writes to it or inside it, and without inheriting.
//
// It exists for .git. The write-denied carveouts live on .git/config and
// .git/hooks as objects, so replacing the .git directory discards them: the
// recreated config and hooks inherit the workspace allow with no deny, which
// restores credential.helper and core.hooksPath. .git cannot simply join
// sandboxFullyProtectedMetadataNames, because DenyWrite's mask includes
// FILE_GENERIC_WRITE and git must write index, objects and refs.
WindowsACLDenyDelete WindowsACLAction = "deny-delete"
)
type WindowsACLEntry struct {
@@ -80,11 +19,6 @@ type WindowsACLEntry struct {
Path string `json:"path"`
Capability string `json:"capability"`
Materialize bool `json:"materialize,omitempty"`
// MaterializeFile makes Materialize create an empty FILE instead of a
// directory. Only meaningful with Materialize. .git/config is the case that
// forces the distinction: created as a directory it does not merely carry
// the wrong ACL, it makes `git init` fail outright.
MaterializeFile bool `json:"materializeFile,omitempty"`
}
type WindowsACLPlan struct {
@@ -106,15 +40,6 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
Path: capability.Root,
Capability: capability.SID,
})
// The grant above carries DELETE, so the same object guard the principal
// plan has must come with it: not DenyWrite (git writes index, objects and
// refs), not materialized (git creates .git, and an empty one breaks
// git init), and not inherited, so everything underneath stays writable.
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyDelete,
Path: windowsRenameProtectedObject(capability.Root),
Capability: capability.SID,
})
for _, path := range capability.ProtectedWriteDenyPaths {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
@@ -123,31 +48,6 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
})
}
}
// Read roots, granted to the read-capability SID.
//
// A profile carrying DenyRead runs the command on a strict token rather than a
// WRITE_RESTRICTED one, and the strict token applies the restricted-SID check
// to reads. Read roots reached that check granted only to the principal's own
// account SID, which the write jail keeps out of the restricting set on
// purpose, so every read failed it — including opening the executable. The
// principal plan still grants the account SID; this is the other half of the
// same grant, so the allow-list and the restriction now come from one place.
readSID, err := windowsReadAllowCapabilitySID(config)
if err != nil {
return WindowsACLPlan{}, err
}
if readSID != "" {
for _, path := range config.PermissionProfile.FileSystem.ReadRoots {
if path = strings.TrimSpace(path); path == "" {
continue
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLAllowRead,
Path: path,
Capability: readSID,
})
}
}
writeSIDs := windowsWriteCapabilitySIDs(writeCapabilities)
for _, path := range config.PermissionProfile.FileSystem.DenyWrite {
path = strings.TrimSpace(path)
@@ -179,39 +79,6 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
}
// windowsPlanVolumeRootGrant returns the first volume root the plan would have
// to change permissions on, or empty when it needs none.
//
// Only elevated setup can write a volume-root DACL, so this is the one entry
// that decides whether a plan is applicable by the unelevated tier at all. The
// check is on the PLAN rather than on the profile because the plan is what gets
// applied: a future entry that lands at a volume root for some other reason is
// caught by the same test.
func windowsPlanVolumeRootGrant(plan WindowsACLPlan) string {
for _, entry := range plan.Entries {
if isWindowsVolumeRoot(normalizeProfilePath(entry.Path)) {
return entry.Path
}
}
return ""
}
// windowsRenameProtectedObject names the object whose DELETE must be denied on
// a write root, whatever trustee holds the grant.
//
// ONE DERIVATION, BECAUSE TWO PLANNERS CONSUME IT. The allow-write mask both
// backends share includes DELETE, and it inherits from the write root onto .git.
// The carveouts that actually protect git live on .git/config and .git/hooks as
// OBJECTS, so renaming .git aside and recreating it discards them: the fresh
// config and hooks inherit the workspace allow with no deny of their own, which
// hands back credential.helper and core.hooksPath. The principal planner denied
// DELETE here and the capability planner did not, so the default restricted-token
// backend was missing the guard entirely. Deriving it in one place is what stops
// a change to the shared mask from updating one consumer and not the other.
func windowsRenameProtectedObject(root string) string {
return filepath.Join(root, sandboxRenameProtectedMetadataName)
}
type windowsWriteRootCapability struct {
Root string
SID string
@@ -273,32 +140,17 @@ func windowsWriteCapabilitySIDs(capabilities []windowsWriteRootCapability) []str
}
func windowsReadDenyCapabilitySIDs(config WindowsSandboxCommandConfig, writeSIDs []string) ([]string, error) {
if len(writeSIDs) > 0 {
return writeSIDs, nil
}
if len(config.PermissionProfile.FileSystem.DenyRead) == 0 {
return nil, nil
}
// The read-capability SID must be denied here as well as granted above. It
// holds read on the read roots, and those start at the filesystem root, so a
// carveout sitting inside one of them would stay readable through that grant
// and the deny list would quietly stop meaning anything.
// Every SID the token can carry has to be denied, not just the newest one.
// With no write roots the token's only capability is ReadOnly, so dropping it
// here would leave the deny naming a SID the command never holds.
denySIDs := append([]string{}, writeSIDs...)
if len(denySIDs) == 0 {
caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome)
if err != nil {
return nil, err
}
denySIDs = append(denySIDs, caps.ReadOnly)
}
readSID, err := windowsReadAllowCapabilitySID(config)
caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome)
if err != nil {
return nil, err
}
if readSID != "" {
denySIDs = append(denySIDs, readSID)
}
return denySIDs, nil
return []string{caps.ReadOnly}, nil
}
func planWindowsDenyReadPaths(paths []string) []string {
@@ -341,19 +193,3 @@ func dedupeWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry {
}
return out
}
// windowsReadAllowCapabilitySID returns the read-capability SID when this profile
// will run on a strict token, and "" otherwise.
//
// Gated on DenyRead because that is exactly what selects the strict token in the
// runner: with no DenyRead the token stays WRITE_RESTRICTED, reads skip the
// restricted-SID check entirely, and granting read on roots as broad as the
// filesystem root would add ACEs that buy nothing. Derived purely from the
// profile, so the setup half and the command half reach the same answer without
// consulting anything ambient.
func windowsReadAllowCapabilitySID(config WindowsSandboxCommandConfig) (string, error) {
if len(config.PermissionProfile.FileSystem.DenyRead) == 0 {
return "", nil
}
return WindowsReadAllowSID(config.SandboxHome)
}
+38 -469
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
@@ -16,121 +15,28 @@ import (
const windowsFileDeleteChild windows.ACCESS_MASK = 0x00000040
type windowsACLPathGroup struct {
Path string
Entries []WindowsACLEntry
Materialize bool
MaterializeFile bool
Path string
Entries []WindowsACLEntry
Materialize bool
}
// windowsACLChainStep is one directory component beneath the anchor, and
// whether THIS run created it. The flag comes from the kernel rather than from
// "it was missing when we looked", because something else can win the gap
// between the probe and the create, and removing a directory the sandbox did not
// make is how a rollback deletes a user's data.
type windowsACLChainStep struct {
Name string
Made bool
}
// windowsACLMaterialization is exactly what materialization created, recorded in
// the shape rollback needs to undo it without resolving a single pathname below
// the anchor.
//
// The anchor is the deepest directory that already existed, and it is the ONLY
// pathname rollback re-resolves. Everything under it is a list of single
// components walked one handle at a time, because a name containing a separator
// is resolved the ordinary way by the kernel and would follow an intermediate
// junction straight out of the approved tree.
type windowsACLMaterialization struct {
AnchorPath string
AnchorID windowsFileIdentity
// Chain is every component between the anchor and the target, shallow to
// deep. All of them are needed to descend at rollback time; only the ones
// with Made set are removed.
Chain []windowsACLChainStep
// File is the leaf file component created inside the deepest Chain entry,
// for the .git/config carveout. Empty when the target is a directory.
File string
FileMade bool
}
func (materialization windowsACLMaterialization) createdAnything() bool {
if materialization.FileMade {
return true
}
for _, step := range materialization.Chain {
if step.Made {
return true
}
}
return false
}
// windowsACLRestoreHook observes restore order. Nil in production; the ordering
// it exposes is load-bearing and was previously pinned by nothing at all,
// despite a comment claiming otherwise.
var windowsACLRestoreHook func(path string)
type windowsACLSnapshot struct {
Path string
Descriptor *windows.SECURITY_DESCRIPTOR
Created windowsACLMaterialization
// TargetID is the object the DACL was read from, so the restore can prove it
// is writing back to that same object rather than to whatever the pathname
// resolves to at rollback time.
//
// A no-follow re-open catches a REPARSE POINT swapped in since apply, and
// that is what the restore relied on. It cannot catch the other
// substitution: rename the target aside and put an ordinary directory of the
// same name in its place. Nothing about that decoy is a reparse point, so the
// re-open succeeds and the old DACL is written onto the attacker's object
// while the real one keeps the aborted setup's ACEs.
//
// Volume serial plus file index is the same identity the materialization
// unwind already anchors on. See reopenWindowsACLDirectoryAsIdentity.
TargetID windowsFileIdentity
Path string
Descriptor *windows.SECURITY_DESCRIPTOR
Materialized bool
}
func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) {
groups := groupWindowsACLPlanByPath(plan)
snapshots := make([]windowsACLSnapshot, 0, len(groups))
abort := func(err error) (func() error, error) {
if rollbackErr := rollbackWindowsACLSnapshots(snapshots); rollbackErr != nil {
return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
}
return nil, err
}
var deferred []windowsACLPathGroup
for _, group := range groups {
snapshot, applied, err := applyWindowsACLPathGroup(group)
if err != nil {
return abort(err)
}
if applied {
snapshots = append(snapshots, snapshot)
continue
}
deferred = append(deferred, group)
}
// A group that applied to nothing was skipped because its target did not
// exist and the group does not materialize one. A LATER group can still
// create it, so the skip has to be retried rather than treated as final.
//
// The .git rename guard is exactly this shape and was silently absent
// because of it. .git must NOT be materialized, since an empty one breaks
// `git init`, so its deny-delete group carries no Materialize. But .git does
// get created, as the parent chain of the .git\config carveout, and that
// group sorts AFTER it. So on every workspace that did not already have a
// .git, the guard was skipped, the directory appeared moments later, and
// nothing went back for it: the principal could then rename .git aside and
// shed the config and hooks carveouts, which is the escape the guard exists
// to stop. Groups that are genuinely absent simply skip again here.
for _, group := range deferred {
snapshot, applied, err := applyWindowsACLPathGroup(group)
if err != nil {
return abort(err)
rollbackErr := rollbackWindowsACLSnapshots(snapshots)
if rollbackErr != nil {
return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
}
return nil, err
}
if applied {
snapshots = append(snapshots, snapshot)
@@ -155,7 +61,6 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
}
group.Entries = append(group.Entries, entry)
group.Materialize = group.Materialize || entry.Materialize
group.MaterializeFile = group.MaterializeFile || entry.MaterializeFile
}
out := make([]windowsACLPathGroup, 0, len(byPath))
for _, group := range byPath {
@@ -179,40 +84,8 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
// elevated setup a lower-privileged local user could swap the target for a
// symlink/junction between operations and redirect the ACL change onto a
// system object it never validated (issue #728, a TOCTOU privilege boundary).
// Reject a malformed group BEFORE touching the filesystem. This validation
// used to run after materialization, so a bad SID or an unknown action
// created a directory chain and only then failed, leaving the error path to
// unwind work that never needed doing. Nothing here depends on isDir: that
// argument only selects the inheritance flag, while the errors come from the
// action lookup and the SID parse.
if _, err := windowsExplicitAccessEntries(group.Entries, false); err != nil {
return windowsACLSnapshot{}, false, err
}
var created windowsACLMaterialization
var handle windows.Handle
// Every exit from here goes through one closure, because there are now two
// things to undo rather than one: the open handle, and whatever
// materialization created. Both are captured by reference and both start
// zero, so calling this before either is set is safe and does nothing.
//
// The unwind is handle-relative. It must never fall back to a pathname
// delete: the failure being cleaned up here can BE the path swap, and
// os.RemoveAll on a swapped ancestor is precisely the recursive elevated
// delete outside the workspace that this cleanup is supposed to prevent.
fail := func(err error) (windowsACLSnapshot, bool, error) {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
if unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil {
return windowsACLSnapshot{}, false, fmt.Errorf("%w; cleanup failed: %v", err, unwindErr)
}
return windowsACLSnapshot{}, false, err
}
var isDir bool
var err error
handle, isDir, err = openWindowsACLTarget(path)
materialized := false
handle, isDir, err := openWindowsACLTarget(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return windowsACLSnapshot{}, false, err
@@ -223,19 +96,25 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
}
return windowsACLSnapshot{}, false, nil
}
// created is assigned even on failure: materialization reports what it
// managed to make before it stopped, and fail() unwinds exactly that.
created, err = materializeWindowsACLTarget(path, group.MaterializeFile)
if err != nil {
return fail(fmt.Errorf("materialize windows ACL target %s: %w", path, err))
if err := os.MkdirAll(path, 0o700); err != nil {
return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err)
}
materialized = true
handle, isDir, err = openWindowsACLTarget(path)
if err != nil {
// This is the branch that fires when the post-create verify catches a
// swap, so it is the single most important cleanup in the file.
return fail(fmt.Errorf("open materialized windows ACL target %s: %w", path, err))
_ = os.RemoveAll(path)
return windowsACLSnapshot{}, false, fmt.Errorf("open materialized windows ACL target %s: %w", path, err)
}
}
// From here the handle is open; every early return must close it first (and
// remove a freshly materialized target) so a failure leaks neither.
fail := func(err error) (windowsACLSnapshot, bool, error) {
_ = windows.CloseHandle(handle)
if materialized {
_ = os.RemoveAll(path)
}
return windowsACLSnapshot{}, false, err
}
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return fail(fmt.Errorf("read windows ACL for %s: %w", path, err))
@@ -259,14 +138,8 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
// The handle has served its purpose (read+write bound to one object) and is
// closed now — rollback re-opens no-follow rather than holding a handle for
// the whole sandbox lifetime, since one caller discards the rollback closure.
// Captured BEFORE the handle closes, because that handle is the only thing
// that names the object rather than the path.
targetID, err := windowsIdentityOfHandle(handle)
if err != nil {
return fail(fmt.Errorf("read windows ACL target identity for %s: %w", path, err))
}
_ = windows.CloseHandle(handle)
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created, TargetID: targetID}, true, nil
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil
}
// openWindowsACLTarget opens path for reading and rewriting its DACL without
@@ -305,12 +178,6 @@ func openWindowsACLTarget(path string) (windows.Handle, bool, error) {
_ = windows.CloseHandle(handle)
return 0, false, fmt.Errorf("refusing to apply ACL to reparse-point target %s: possible path swap during elevated setup", path)
}
// Ancestors are resolved by CreateFile even with FILE_FLAG_OPEN_REPARSE_POINT,
// so the check above is not enough on its own.
if err := verifyWindowsACLTargetNotRedirected(handle, path); err != nil {
_ = windows.CloseHandle(handle)
return 0, false, err
}
isDir := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
return handle, isDir, nil
}
@@ -339,19 +206,10 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind
if err != nil {
return nil, err
}
entryInheritance := inheritance
// DenyDelete governs the object it names and nothing beneath it. Inherited
// onto .git's children it would deny DELETE on every file inside, so git
// could not remove a lock file, a ref, or anything else it rewrites, and
// the guard would read as a broken repository rather than as a blocked
// rename.
if entry.Action == WindowsACLDenyDelete {
entryInheritance = windows.NO_INHERITANCE
}
out = append(out, windows.EXPLICIT_ACCESS{
AccessPermissions: permissions,
AccessMode: accessMode,
Inheritance: entryInheritance,
Inheritance: inheritance,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_GROUP,
@@ -365,59 +223,11 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind
func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) {
switch action {
case WindowsACLAllowWrite:
// DELETE is part of the grant, not an extra. FILE_GENERIC_WRITE covers
// creating and modifying but not removing or renaming, and a rename needs
// delete access on the source. Under the old same-user token that gap was
// invisible, because the caller already held inherited rights on its own
// tree; a sandbox principal is a separate account with no such
// inheritance, so without DELETE it can write a file it can never delete.
// Ordinary editing and most git operations rewrite files by replacing
// them, so the omission fails normal work rather than an edge case.
//
// FILE_DELETE_CHILD is deliberately NOT granted, for the same reason
// WRITE_DAC and WRITE_OWNER are not. On a parent it authorises deleting a
// child whatever the child's own DACL says, so granting it on a write root
// hands back the write-denied carve-outs underneath it: a principal could
// delete .git/config and recreate it, and the replacement inherits this
// grant with no deny of its own — restoring exactly the credential.helper
// and core.hooksPath control the carve-out exists to prevent.
//
// It was granted here originally to keep the mask symmetric with
// WindowsACLDenyWrite, which does treat FILE_DELETE_CHILD as part of
// write. Symmetry is the wrong goal: denying a capability is not a reason
// to grant it. DELETE alone covers removing and renaming files the
// principal owns inside its roots, which is what the grant is for.
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE, nil
case WindowsACLAllowRead:
// Read and traverse without write. A sandbox principal is a separate
// account with no inherent access to the caller's tree, so a read-only
// root has to be granted rather than assumed. Deliberately omits
// FILE_GENERIC_WRITE, DELETE and WRITE_DAC.
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
case windowsACLRevoke:
// REVOKE_ACCESS drops every ACE naming the trustee regardless of the mask,
// so the mask is ignored here. Used to retire a principal without having
// to remember which access each path was granted.
return windows.REVOKE_ACCESS, 0, nil
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE, nil
case WindowsACLDenyRead:
return windows.DENY_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
case WindowsACLDenyWrite:
return windows.DENY_ACCESS, windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER, nil
case WindowsACLDenyDelete:
// Deny removing or RENAMING the object itself, nothing more. Renaming a
// directory needs DELETE on that directory, so denying DELETE is what
// stops .git being moved aside and recreated without its carveouts.
//
// WRITE_DAC and WRITE_OWNER come along because a guard the principal can
// rewrite, or take ownership of and then rewrite, is not a guard.
//
// FILE_GENERIC_WRITE is deliberately absent: git writes index, objects and
// refs constantly, and denying it would break every commit rather than the
// rename. FILE_DELETE_CHILD is absent for the same reason one level down,
// since git deletes its own lock files and refs. Neither is needed here:
// this ACE does not inherit (see windowsExplicitAccessEntries), so it
// governs the .git directory object alone.
return windows.DENY_ACCESS, windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER, nil
default:
return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action)
}
@@ -425,18 +235,11 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC
func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
var errs []error
// Reverse order is load-bearing twice over. Groups are sorted ascending by
// path key and an ancestor key is always a proper prefix of its descendants,
// so walking backwards unwinds descendant before ancestor: a materialized
// directory is therefore empty by the time its own removal is attempted.
// Restoring ACLs in the same order is independently right, because
// SetSecurityInfo propagates inheritable ACEs down, so the ancestor must go
// last. TestRollbackUnwindsDescendantsBeforeAncestors pins it.
for index := len(snapshots) - 1; index >= 0; index-- {
snapshot := snapshots[index]
if snapshot.Created.createdAnything() {
if err := rollbackWindowsACLMaterialization(snapshot.Created); err != nil {
errs = append(errs, err)
if snapshot.Materialized {
if err := os.RemoveAll(snapshot.Path); err != nil {
errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err))
}
continue
}
@@ -445,36 +248,16 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err))
continue
}
// Re-open no-follow AND prove it is the same object. The no-follow open
// alone only rules out a reparse point swapped in since apply; a target
// renamed aside and replaced by an ordinary directory of the same name
// passes it, and the old DACL would land on the decoy while the real
// object kept the aborted setup's ACEs. See windowsACLSnapshot.TargetID.
// Re-open no-follow rather than restoring by pathname: the restore must
// land on the real object, not a reparse point swapped in since apply. The
// residual window is small because the target is ACL-restricted by now, but
// a handle keeps the restore honest. On a materialized-target rollback we
// remove it above, so only the restore-existing path opens here.
handle, _, err := openWindowsACLTarget(snapshot.Path)
if err != nil {
errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err))
continue
}
if !snapshot.TargetID.empty() {
got, identityErr := windowsIdentityOfHandle(handle)
if identityErr != nil {
_ = windows.CloseHandle(handle)
errs = append(errs, fmt.Errorf("read rollback identity for %s: %w", snapshot.Path, identityErr))
continue
}
if got != snapshot.TargetID {
// REFUSED, not forced through. Leaving the real object with the
// aborted setup's ACEs is the safe direction: the caller is failing
// anyway, and writing a stale DACL onto an object setup never
// touched is the one outcome rollback must not produce.
_ = windows.CloseHandle(handle)
errs = append(errs, fmt.Errorf("refusing to restore windows ACL for %s: it is no longer the object setup applied to", snapshot.Path))
continue
}
}
if windowsACLRestoreHook != nil {
windowsACLRestoreHook(snapshot.Path)
}
if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
errs = append(errs, fmt.Errorf("rollback windows ACL for %s: %w", snapshot.Path, err))
}
@@ -482,217 +265,3 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
}
return errors.Join(errs...)
}
// materializeWindowsACLTarget creates a missing ACL target with the shape the
// owning tool expects. A directory target is created whole; a file target gets
// its parent chain created and then an empty file, because creating it as a
// directory would break the tool that owns it rather than just mis-ACL it.
// The returned record is meaningful even when the error is non-nil: a chain that
// got three levels deep and then failed still has three levels to unwind.
func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterialization, error) {
directory := path
leaf := ""
if asFile {
directory = filepath.Dir(path)
leaf = filepath.Base(path)
}
created, parent, err := makeWindowsACLDirChainNoFollow(directory)
if err != nil {
return created, err
}
defer func() { _ = windows.CloseHandle(parent) }()
if !asFile {
return created, nil
}
// A racing creator winning is still fine: the target exists, which is all
// materialization needed. createWindowsACLChildFile reports that as
// created=false, so rollback will not delete a file the sandbox did not make.
created.File = leaf
madeFile, err := createWindowsACLChildFile(parent, leaf)
created.FileMade = madeFile
return created, err
}
// rollbackWindowsACLMaterialization removes exactly what materialization
// created, deepest first, without resolving any pathname below the anchor.
//
// This is the other half of the pathname problem. The old cleanup called
// os.RemoveAll on the target pathname, which re-resolves every ancestor at the
// moment it runs, so an ancestor swapped to a junction after the object was
// created sent a recursive elevated delete into an unrelated tree. It also only
// ever removed the final component, leaving every intermediate directory the
// chain had created behind.
//
// The anchor is the one pathname that has to be resolved again, and it is
// checked by file identity rather than by name: replacing a directory with
// another REAL directory of the same name needs no reparse point at all and
// would otherwise pass every no-follow check there is.
//
// Residue is preferable to over-deletion throughout. When something cannot be
// removed safely this reports it and leaves it, and never falls back to a
// pathname delete.
func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization) error {
if !materialization.createdAnything() {
return nil
}
anchor, err := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// The anchor is gone, so everything created beneath it is gone too.
// Nothing to undo, and no way to undo it if there were.
return nil
}
return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err)
}
// One handle per level: handles[i] is the parent of Chain[i], which is what
// deleting Chain[i] relative to a pinned parent requires.
handles := []windows.Handle{anchor}
defer func() {
for _, handle := range handles {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
}
}()
// Descend only as far as is actually required. Removing a directory needs its
// PARENT's handle, not its own, so the deepest component is opened only when
// a file leaf lives inside it. This is not just economy: the deepest
// component is usually the ACL target itself, so it may already carry the
// deny-read ACE this rollback is undoing, and opening it would be refused by
// the very ACL being unwound.
needed := len(materialization.Chain)
if !materialization.FileMade && needed > 0 {
needed--
}
depth := 0
for ; depth < needed; depth++ {
child, err := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name)
if err != nil {
// Already removed by something else. Stop descending; whatever is
// below it is gone with it.
if isWindowsNotExist(err) {
break
}
return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err)
}
handles = append(handles, child)
}
var errs []error
// The file leaf lives inside the deepest chain directory, so it goes first
// and only if the descent actually reached that far.
if materialization.FileMade && depth == needed {
if err := deleteWindowsACLChildFile(handles[len(handles)-1], materialization.File); err != nil {
errs = append(errs, fmt.Errorf("remove materialized windows ACL file %s: %w", materialization.File, err))
}
}
// Chain[i] is removed through handles[i], so the deepest one that can be
// removed is bounded by how far the descent actually got.
deepest := len(handles) - 1
if last := len(materialization.Chain) - 1; deepest > last {
deepest = last
}
for index := deepest; index >= 0; index-- {
// Close the child's own handle, if the descent opened one, before asking
// its parent to remove it. A directory with a live handle open still
// counts as present, so the parent's delete would come back as non-empty.
if index+1 < len(handles) && handles[index+1] != 0 {
_ = windows.CloseHandle(handles[index+1])
handles[index+1] = 0
}
if !materialization.Chain[index].Made {
continue
}
if err := deleteWindowsACLChildDirectory(handles[index], materialization.Chain[index].Name); err != nil {
errs = append(errs, fmt.Errorf("remove materialized windows ACL directory %s: %w", materialization.Chain[index].Name, err))
}
}
return errors.Join(errs...)
}
// windowsACLMaterializeSwapHook is a test seam and nothing else. It fires inside
// makeWindowsACLDirChainNoFollow at the exact instant the race used to be
// exploitable: the anchor is verified and pinned, and nothing has been created
// yet. A race reproducible only by luck is not a regression test, so the instant
// is made addressable rather than hoped for. Always nil in production.
var windowsACLMaterializeSwapHook func(anchor string)
// makeWindowsACLDirChainNoFollow is an os.MkdirAll that never resolves a
// pathname below its anchor.
//
// It walks UP to the deepest ancestor that already exists and opens it
// no-follow. Because GetFinalPathNameByHandle answers for the whole resolved
// path, that single check clears every ancestor above it too. Then it walks back
// DOWN, creating one component at a time relative to the HANDLE of the level
// above, so the tree it descends is pinned to objects rather than named by
// strings.
//
// That is the difference that matters. This used to verify a component by
// pathname, close the handle, and then hand the same string to os.Mkdir: two
// independent kernel resolutions with a gap between them. A workspace owner who
// swapped the verified ancestor for a junction inside that gap got the component
// created outside the approved tree, as Administrator, and verifying again
// afterwards cannot un-create it. Junctions need no privilege, so this was
// reachable by exactly the unprivileged user the sandbox exists to contain.
//
// It deliberately does NOT re-verify each created component by pathname. A child
// created relative to a pinned parent is in the right place by construction, so
// comparing pathnames afterwards would add nothing and would reject correct
// creates whenever the tree was legitimately renamed mid-setup.
//
// Returns what it created, plus an open handle to the deepest directory which
// the caller must close.
func makeWindowsACLDirChainNoFollow(dir string) (windowsACLMaterialization, windows.Handle, error) {
cleaned := filepath.Clean(strings.TrimSpace(dir))
if cleaned == "" || cleaned == "." {
return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target: empty directory path %q", dir)
}
// Walk up to the deepest ancestor that exists, collecting the component
// NAMES that are missing. Names, not paths: everything below the anchor is
// addressed relative to a handle from here on.
var missing []string
current := cleaned
var anchor windows.Handle
var anchorID windowsFileIdentity
for {
handle, identity, err := openWindowsACLDirectoryNoFollowWithIdentity(current)
if err == nil {
anchor, anchorID = handle, identity
break
}
if !errors.Is(err, os.ErrNotExist) {
return windowsACLMaterialization{}, 0, err
}
parent := filepath.Dir(current)
if parent == current {
return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir)
}
missing = append(missing, filepath.Base(current))
current = parent
}
created := windowsACLMaterialization{AnchorPath: current, AnchorID: anchorID}
if hook := windowsACLMaterializeSwapHook; hook != nil {
hook(current)
}
// Walk back down, one component per handle. The anchor handle is released as
// soon as its child is open, so at most two levels are held at once.
parent := anchor
for index := len(missing) - 1; index >= 0; index-- {
name := missing[index]
child, madeNow, err := createWindowsACLChildDirectory(parent, name)
if err != nil {
_ = windows.CloseHandle(parent)
return created, 0, err
}
created.Chain = append(created.Chain, windowsACLChainStep{Name: name, Made: madeNow})
_ = windows.CloseHandle(parent)
parent = child
}
return created, parent, nil
}
@@ -37,15 +37,8 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) {
if !applied {
t.Fatal("applied = false, want true for an existing directory target")
}
if snapshot.Path != dir {
t.Fatalf("snapshot.Path = %q, want %q", snapshot.Path, dir)
}
// The target already existed, so nothing was created and rollback must have
// nothing to remove. Asserting the chain rather than a bool matters: a
// rewiring that recorded the walked components instead of only the created
// ones would make rollback delete a directory the sandbox never made.
if snapshot.Created.createdAnything() {
t.Fatalf("snapshot recorded %#v as created for a target that already existed", snapshot.Created)
if snapshot.Path != dir || snapshot.Materialized {
t.Fatalf("snapshot = %#v, want Path=%q Materialized=false", snapshot, dir)
}
if snapshot.Descriptor == nil {
t.Fatal("snapshot has no captured descriptor to roll back to")
@@ -74,16 +67,8 @@ func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) {
if err != nil {
t.Fatalf("applyWindowsACLPathGroup: %v", err)
}
if !applied {
t.Fatal("applied = false, want true for a materialized target")
}
// Exactly one component was missing, so exactly one must be recorded as
// created, and it must be the leaf's own name rather than a path.
if len(snapshot.Created.Chain) != 1 || snapshot.Created.Chain[0] != (windowsACLChainStep{Name: "created", Made: true}) {
t.Fatalf("created chain = %#v, want one step {created true}", snapshot.Created.Chain)
}
if snapshot.Created.AnchorPath != filepath.Dir(target) {
t.Fatalf("anchor = %q, want the existing parent %q", snapshot.Created.AnchorPath, filepath.Dir(target))
if !applied || !snapshot.Materialized {
t.Fatalf("applied=%v materialized=%v, want both true", applied, snapshot.Materialized)
}
if _, err := os.Stat(target); err != nil {
t.Fatalf("materialized target not created: %v", err)
@@ -1,122 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
// gitGuardDenyDeleteApplied reports whether the applied DACL on path carries a
// DENY ace granting DELETE to sid. Read back from the object rather than from
// the plan, because the plan is what was already wrong.
func gitGuardDenyDeleteApplied(t *testing.T, path string, sid string) bool {
t.Helper()
handle, _, err := openWindowsACLTarget(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer func() { _ = windows.CloseHandle(handle) }()
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Fatalf("read the DACL of %s: %v", path, err)
}
dacl, _, err := descriptor.DACL()
if err != nil || dacl == nil {
t.Fatalf("read the DACL of %s: %v", path, err)
}
wanted, err := windows.StringToSid(sid)
if err != nil {
t.Fatalf("parse the capability SID %q: %v", sid, err)
}
// GetAce hands back a generic header that is reinterpreted here. Sound only
// for the fixed-layout ACE types: an object ACE carries Flags and two GUIDs
// ahead of the trustee, so SidStart would land mid-structure. Allowed and
// denied ACEs share that fixed layout, and nothing under test builds an
// object ACE, so a type this does not recognise is skipped rather than
// decoded into a nonsense SID.
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, index, &ace); err != nil {
continue
}
if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
continue
}
aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
if !aceSID.Equals(wanted) {
continue
}
if ace.Mask&windows.DELETE != 0 {
return true
}
}
return false
}
// THE GUARD BELONGS TO THE GRANT, NOT TO ONE PLANNER.
//
// The allow-write mask both backends share includes DELETE, and it inherits from
// the write root onto .git. What protects git is attached to .git/config and
// .git/hooks as OBJECTS, so renaming .git aside and recreating it discards those
// carveouts: the fresh config and hooks inherit the workspace allow with no deny
// of their own, which hands back credential.helper and core.hooksPath.
//
// The principal planner denied DELETE on .git; the capability planner did not,
// and the capability backend is the default. So on the reachable path the
// caller's own token and the capability restricting SID could both authorise the
// rename.
//
// Read back from the applied object, not asserted on the plan, because the plan
// is exactly what was wrong. DACL edits on a user-owned directory need no
// Administrator rights, so this runs unelevated.
func TestCapabilityPlanDeniesDeleteOnGit(t *testing.T) {
workspace := t.TempDir()
gitDir := filepath.Join(workspace, ".git")
if err := os.MkdirAll(gitDir, 0o700); err != nil {
t.Fatal(err)
}
config := WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
CommandCWD: workspace,
WorkspaceRoots: []string{workspace},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{Root: workspace}},
},
},
}
plan, err := BuildWindowsACLPlan(config)
if err != nil {
t.Fatalf("BuildWindowsACLPlan: %v", err)
}
sid, err := windowsCapabilitySIDForWriteRoot(config, workspace)
if err != nil {
t.Fatalf("resolve the workspace capability SID: %v", err)
}
// SETUP: the plan must actually grant this root, or the guard below would be
// vacuously satisfied by a plan that grants nothing.
granted := false
for _, entry := range plan.Entries {
if entry.Action == WindowsACLAllowWrite && entry.Path == workspace {
granted = true
}
}
if !granted {
t.Fatalf("SETUP INVALID: the capability plan does not grant write on %s", workspace)
}
if _, err := applyWindowsACLPlan(plan); err != nil {
t.Skipf("cannot apply an ACL plan here: %v", err)
}
if !gitGuardDenyDeleteApplied(t, gitDir, sid) {
t.Fatalf(".git carries no deny-DELETE ace for the capability SID %s, so the sandboxed token can rename it and recreate it without the config and hooks carveouts", sid)
}
}
@@ -1,94 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// makeJunction points link at target, skipping the test when the environment
// refuses to create one. A junction needs no privilege, which is exactly why
// this attack is reachable by an ordinary workspace owner.
func makeJunction(t *testing.T, link, target string) {
t.Helper()
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
t.Skipf("cannot create a junction here: %v: %s", err, out)
}
// Assert it actually redirects. A junction that silently did nothing would
// green this test while proving nothing.
probe := filepath.Join(link, "redirect-probe")
if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil {
t.Fatalf("write through junction: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "redirect-probe")); err != nil {
t.Fatalf("junction does not redirect, so this test would prove nothing: %v", err)
}
if err := os.Remove(probe); err != nil {
t.Fatalf("clean probe: %v", err)
}
}
// Elevated setup must not create anything through a reparse-point ancestor.
//
// FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL component being followed.
// materializeWindowsACLTarget used os.MkdirAll/os.OpenFile on the pathname, both
// of which resolve ancestors, so a workspace owner who turned .git into a
// junction before setup ran got objects created at a location of their choosing
// — as Administrator — and the no-follow check only rejected it afterwards, far
// too late to un-create them.
func TestMaterializeRefusesAncestorJunctionBeforeCreating(t *testing.T) {
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
t.Run(name, func(t *testing.T) {
external := t.TempDir()
workspace := t.TempDir()
gitDir := filepath.Join(workspace, ".git")
makeJunction(t, gitDir, external)
target := filepath.Join(gitDir, "hooks", "config")
_, err := materializeWindowsACLTarget(target, asFile)
if err == nil {
t.Fatalf("materialized %s through a junction ancestor instead of refusing", target)
}
if !strings.Contains(err.Error(), "reparse") {
t.Fatalf("refused for the wrong reason: %v", err)
}
// Nothing may survive on the other side of the junction. The old code
// left every intermediate directory MkdirAll had created.
leaked, lerr := os.ReadDir(external)
if lerr != nil {
t.Fatalf("read external dir: %v", lerr)
}
if len(leaked) != 0 {
names := make([]string, 0, len(leaked))
for _, entry := range leaked {
names = append(names, entry.Name())
}
t.Fatalf("created %v outside the workspace through the junction", names)
}
})
}
}
// The ordinary path still works: no reparse point anywhere, target gets made.
func TestMaterializeStillCreatesOrdinaryTargets(t *testing.T) {
root := t.TempDir()
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
t.Run(name, func(t *testing.T) {
target := filepath.Join(root, name, "nested", "deeper", "target")
if _, err := materializeWindowsACLTarget(target, asFile); err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
info, err := os.Stat(target)
if err != nil {
t.Fatalf("target was not created: %v", err)
}
if info.IsDir() == asFile {
t.Fatalf("target isDir=%v, wanted file=%v", info.IsDir(), asFile)
}
})
}
}
@@ -1,453 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// swapAncestorAside moves approved out of the way and leaves a junction wearing
// its name, pointing at elsewhere. This is the whole attack in three lines, and
// it needs no privilege: junctions are creatable by any user, which is exactly
// why an unprivileged workspace owner can aim elevated setup wherever they like.
//
// Returns the path the real directory now lives at.
func swapAncestorAside(t *testing.T, approved, elsewhere string) string {
t.Helper()
moved := approved + "-moved"
if err := os.Rename(approved, moved); err != nil {
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
}
makeJunction(t, approved, elsewhere)
return moved
}
// requireNothingEscaped fails when anything at all was created on the far side
// of the junction.
func requireNothingEscaped(t *testing.T, elsewhere string) {
t.Helper()
leaked, err := os.ReadDir(elsewhere)
if err != nil {
t.Fatalf("read the decoy directory: %v", err)
}
if len(leaked) == 0 {
return
}
names := make([]string, 0, len(leaked))
for _, entry := range leaked {
names = append(names, entry.Name())
}
t.Fatalf("ESCAPED: created %v outside the approved tree, as Administrator", names)
}
// THE MATERIALIZATION RACE, ON THE PRODUCTION CALL PATH.
//
// The existing junction test plants its junction before the walk even starts, so
// the very first check sees it and refuses. That proves the easy half. The half
// the reviewer filed is the swap that happens AFTER a component has been
// verified and BEFORE it is used, and no test reached it: a race nobody can
// trigger on demand is not a regression test, so makeWindowsACLDirChainNoFollow
// carries a seam that fires at exactly that instant.
//
// The control arm matters as much as the fixed one. It performs the identical
// swap and then does what this code used to do, creating by pathname, and
// asserts that the object DOES escape. Without it, the fixed arm passing proves
// only that some code ran, not that the hole it closes was ever open.
func TestMaterializeSurvivesAnAncestorSwappedMidWalk(t *testing.T) {
t.Run("control: creating by pathname escapes", func(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "ws")
elsewhere := filepath.Join(root, "OUTSIDE")
for _, dir := range []string{approved, elsewhere} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("seed %s: %v", dir, err)
}
}
// Verified, exactly as the walk verifies its anchor.
if err := verifyWindowsACLPathComponentNotRedirected(approved); err != nil {
t.Fatalf("anchor did not verify before the swap: %v", err)
}
moved := swapAncestorAside(t, approved, elsewhere)
// The old create: a pathname, re-resolved by the kernel right now.
if err := os.MkdirAll(filepath.Join(approved, "a", "b"), 0o700); err != nil {
t.Fatalf("pathname create: %v", err)
}
if _, err := os.Stat(filepath.Join(elsewhere, "a", "b")); err != nil {
t.Fatalf("the control arm did not reproduce the escape, so the fixed arm below proves nothing: %v", err)
}
if _, err := os.Stat(filepath.Join(moved, "a", "b")); err == nil {
t.Error("the control arm created inside the verified directory, which is not the behaviour being contrasted")
}
})
t.Run("fixed: creating through the pinned handle stays put", func(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "ws")
elsewhere := filepath.Join(root, "OUTSIDE")
for _, dir := range []string{approved, elsewhere} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("seed %s: %v", dir, err)
}
}
var moved string
swapped := false
windowsACLMaterializeSwapHook = func(anchor string) {
if swapped {
return
}
swapped = true
if anchor != approved {
t.Errorf("anchored on %q, want the deepest existing ancestor %q", anchor, approved)
}
moved = swapAncestorAside(t, approved, elsewhere)
}
t.Cleanup(func() { windowsACLMaterializeSwapHook = nil })
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false)
if err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
if !swapped {
t.Fatal("the seam never fired, so the swap never happened and this test proved nothing")
}
requireNothingEscaped(t, elsewhere)
if _, err := os.Stat(filepath.Join(moved, "a", "b")); err != nil {
t.Errorf("the target did not land in the directory that was verified: %v", err)
}
// And the record must describe what to unwind, in components rather than
// paths, shallowest first.
if len(created.Chain) != 2 || created.Chain[0].Name != "a" || created.Chain[1].Name != "b" {
t.Fatalf("created chain = %#v, want [a b] shallow to deep", created.Chain)
}
for _, step := range created.Chain {
if !step.Made {
t.Errorf("component %q was not recorded as created, so rollback would leave it behind", step.Name)
}
}
})
}
// The FILE target has the same race, and it is the one that matters most:
// .git/config is materialized as a file on every stock setup, and it is the file
// whose credential.helper is worth stealing.
func TestMaterializeFileSurvivesAnAncestorSwappedMidWalk(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "ws")
elsewhere := filepath.Join(root, "OUTSIDE")
for _, dir := range []string{approved, elsewhere} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("seed %s: %v", dir, err)
}
}
var moved string
swapped := false
windowsACLMaterializeSwapHook = func(string) {
if swapped {
return
}
swapped = true
moved = swapAncestorAside(t, approved, elsewhere)
}
t.Cleanup(func() { windowsACLMaterializeSwapHook = nil })
created, err := materializeWindowsACLTarget(filepath.Join(approved, ".git", "config"), true)
if err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
if !swapped {
t.Fatal("the seam never fired, so this test proved nothing")
}
requireNothingEscaped(t, elsewhere)
landed := filepath.Join(moved, ".git", "config")
info, err := os.Stat(landed)
if err != nil {
t.Fatalf("the file did not land in the directory that was verified: %v", err)
}
if info.IsDir() {
t.Error("materialized .git/config as a directory, which breaks git init")
}
if !created.FileMade || created.File != "config" {
t.Errorf("file record = %q made=%v, want config/true", created.File, created.FileMade)
}
}
// THE ROLLBACK RACE. The ancestor is swapped AFTER the target was created, which
// is the window the teardown path lives in: minutes or hours, not microseconds.
//
// The bystander is the point. If the unwind resolves by pathname it walks into
// the decoy and deletes what it finds there, recursively and elevated. Its
// survival is the only thing that proves the unwind did not.
func TestRollbackDoesNotFollowAnAncestorSwappedAfterCreation(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "ws")
elsewhere := filepath.Join(root, "OUTSIDE")
for _, dir := range []string{approved, elsewhere} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("seed %s: %v", dir, err)
}
}
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false)
if err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
// A bystander tree under the decoy, shaped exactly like what we created, so a
// pathname unwind would find something to destroy at every level.
bystander := filepath.Join(elsewhere, "a", "b")
if err := os.MkdirAll(bystander, 0o700); err != nil {
t.Fatalf("seed bystander: %v", err)
}
witness := filepath.Join(bystander, "irreplaceable.txt")
if err := os.WriteFile(witness, []byte("not yours to delete"), 0o600); err != nil {
t.Fatalf("seed witness: %v", err)
}
moved := swapAncestorAside(t, approved, elsewhere)
// The anchor pathname now names the decoy, and the decoy is a junction, so
// the unwind must refuse rather than proceed. Either way it must not delete.
err = rollbackWindowsACLMaterialization(created)
if _, statErr := os.Stat(witness); statErr != nil {
t.Fatalf("DESTRUCTIVE: rollback followed the junction and deleted a tree outside the approved directory: %v", statErr)
}
if _, statErr := os.Stat(bystander); statErr != nil {
t.Fatalf("DESTRUCTIVE: rollback removed the bystander directory outside the approved directory: %v", statErr)
}
if err == nil {
t.Error("rollback reported success while unwinding through a swapped ancestor; it must say it could not")
}
// Residue inside the real tree is the accepted price: leaving it is strictly
// better than a recursive delete through a path someone else controls.
if _, statErr := os.Stat(filepath.Join(moved, "a", "b")); statErr != nil {
t.Logf("note: the real tree was also unwound (%v); leaving it would be acceptable too", statErr)
}
}
// A real directory wearing the anchor's name is a swap with NO reparse point
// anywhere, so every no-follow check in this package passes it. Only the file
// identity notices.
func TestRollbackRefusesAnAnchorReplacedByARealDirectory(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "ws")
if err := os.Mkdir(approved, 0o700); err != nil {
t.Fatalf("seed: %v", err)
}
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a"), false)
if err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
if err := os.Rename(approved, approved+"-moved"); err != nil {
t.Skipf("cannot rename here: %v", err)
}
// An ordinary directory. Nothing is a link; nothing is a reparse point.
if err := os.Mkdir(approved, 0o700); err != nil {
t.Fatalf("plant the replacement: %v", err)
}
decoy := filepath.Join(approved, "a")
if err := os.Mkdir(decoy, 0o700); err != nil {
t.Fatalf("plant the decoy child: %v", err)
}
err = rollbackWindowsACLMaterialization(created)
if err == nil {
t.Error("rollback accepted a different directory wearing the anchor's name")
} else if !strings.Contains(err.Error(), "no longer the directory") {
t.Errorf("refused for the wrong reason: %v", err)
}
if _, statErr := os.Stat(decoy); statErr != nil {
t.Errorf("rollback deleted a directory it never created: %v", statErr)
}
}
// Rollback removes ONLY what this run created. A pre-existing ancestor is walked
// through and left alone.
func TestRollbackLeavesDirectoriesItDidNotCreate(t *testing.T) {
root := t.TempDir()
existing := filepath.Join(root, "ws", "already-here")
if err := os.MkdirAll(existing, 0o700); err != nil {
t.Fatalf("seed: %v", err)
}
target := filepath.Join(existing, "made", "deeper")
created, err := materializeWindowsACLTarget(target, false)
if err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
if created.AnchorPath != existing {
t.Fatalf("anchor = %q, want the deepest pre-existing directory %q", created.AnchorPath, existing)
}
if err := rollbackWindowsACLMaterialization(created); err != nil {
t.Fatalf("rollbackWindowsACLMaterialization: %v", err)
}
if _, err := os.Stat(filepath.Join(existing, "made")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("created directory survived rollback: stat err = %v", err)
}
if _, err := os.Stat(existing); err != nil {
t.Errorf("rollback removed a directory that already existed: %v", err)
}
}
// The whole apply path, through the closure callers actually hold, rather than
// through rollbackWindowsACLSnapshots directly. Every other rollback test in
// this package calls the unwind by hand, which cannot catch applyWindowsACLPlan
// failing to carry the created record into the snapshots it hands over.
func TestApplyWindowsACLPlanClosureRemovesWhatItMaterialized(t *testing.T) {
root := t.TempDir()
directoryTarget := filepath.Join(root, "ws", "hooks")
fileTarget := filepath.Join(root, "ws", "config")
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
{Action: WindowsACLDenyWrite, Path: directoryTarget, Capability: "S-1-1-0", Materialize: true},
{Action: WindowsACLDenyWrite, Path: fileTarget, Capability: "S-1-1-0", Materialize: true, MaterializeFile: true},
}}
rollback, err := applyWindowsACLPlan(plan)
if err != nil {
t.Fatalf("applyWindowsACLPlan: %v", err)
}
for _, path := range []string{directoryTarget, fileTarget} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s was not materialized: %v", path, err)
}
}
if err := rollback(); err != nil {
t.Fatalf("rollback closure: %v", err)
}
for _, path := range []string{directoryTarget, fileTarget} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Errorf("%s survived the rollback closure: stat err = %v", path, err)
}
}
// The shared prefix both targets needed must go too, and it is created by
// whichever group runs first rather than being owned by both.
if _, err := os.Stat(filepath.Join(root, "ws")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the shared parent survived: stat err = %v", err)
}
}
// A rollback that cannot remove something must SAY so. This is the regression
// guard for the trap a naive handle-relative port walks straight into:
// FILE_DELETE_ON_CLOSE reports success on a non-empty directory and leaves it
// there, which turns a loud failure into a silent lie. The directory being
// populated is not adversarial; .git/hooks fills up the moment git runs.
func TestRollbackReportsWhatItCouldNotRemove(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ws", "hooks")
created, err := materializeWindowsACLTarget(target, false)
if err != nil {
t.Fatalf("materializeWindowsACLTarget: %v", err)
}
if err := os.WriteFile(filepath.Join(target, "pre-commit"), []byte("#!/bin/sh\n"), 0o600); err != nil {
t.Fatalf("populate: %v", err)
}
err = rollbackWindowsACLMaterialization(created)
if err == nil {
t.Fatal("rollback reported success on a directory it could not empty, so callers cannot tell teardown failed")
}
if !strings.Contains(strings.ToLower(err.Error()), "hooks") {
t.Errorf("the error does not name what was left behind: %v", err)
}
// Left in place deliberately. Removing it would mean recursing, and recursion
// through a path the workspace owner controls is the thing being avoided.
if _, statErr := os.Stat(target); statErr != nil {
t.Errorf("rollback recursed into a populated directory instead of reporting it: %v", statErr)
}
}
// The primitives take a single component and the walk relies on that. A joined
// name is resolved the ordinary way by the kernel, so an intermediate junction
// inside it is followed and the object lands outside the anchor: the pinned
// parent buys nothing if the name itself walks.
func TestChildOperationsRefuseNamesThatAreNotSingleComponents(t *testing.T) {
root := t.TempDir()
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
for _, name := range []string{`sub\child`, "sub/child", "..", ".", "", `C:\absolute`, "stream:name"} {
t.Run("create dir "+name, func(t *testing.T) {
handle, _, err := createWindowsACLChildDirectory(parent, name)
if err == nil {
_ = windows.CloseHandle(handle)
t.Fatalf("accepted %q, which the kernel would resolve through intermediate directories", name)
}
})
t.Run("delete dir "+name, func(t *testing.T) {
if err := deleteWindowsACLChildDirectory(parent, name); err == nil {
t.Fatalf("accepted %q for deletion", name)
}
})
t.Run("create file "+name, func(t *testing.T) {
if _, err := createWindowsACLChildFile(parent, name); err == nil {
t.Fatalf("accepted %q for file creation", name)
}
})
}
}
// A junction sitting where a chain component should be must be refused when it
// is OPENED, not merely when it is created. FILE_OPEN_REPARSE_POINT hands back a
// handle to the junction itself, and using that as the next parent puts every
// deeper create on the far side of it.
func TestChildOperationsRefuseAnExistingJunction(t *testing.T) {
root := t.TempDir()
elsewhere := t.TempDir()
makeJunction(t, filepath.Join(root, "hop"), elsewhere)
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
handle, _, err := createWindowsACLChildDirectory(parent, "hop")
if err == nil {
_ = windows.CloseHandle(handle)
t.Error("createWindowsACLChildDirectory returned a junction as the next parent in the walk")
}
opened, err := openWindowsACLChildDirectory(parent, "hop")
if err == nil {
_ = windows.CloseHandle(opened)
t.Error("openWindowsACLChildDirectory returned a junction to descend through")
}
}
// Rollback descends; it must never create. If a component was removed by
// something else in the meantime, re-making it and then deleting it would remove
// a directory the sandbox never made.
func TestRollbackDescentNeverCreatesAMissingComponent(t *testing.T) {
root := t.TempDir()
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
handle, err := openWindowsACLChildDirectory(parent, "never-existed")
if err == nil {
_ = windows.CloseHandle(handle)
t.Fatal("the descent open created a directory that did not exist")
}
if !isWindowsNotExist(err) {
t.Errorf("a missing component reported %v, which rollback cannot distinguish from a real failure", err)
}
if _, statErr := os.Stat(filepath.Join(root, "never-existed")); statErr == nil {
t.Error("a directory appeared on disk from an open that should never create")
}
}
@@ -1,470 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
// Handle-relative directory operations.
//
// WHY THESE EXIST. Every pathname-based call re-resolves the whole path inside
// the kernel at the moment it runs. So verifying a component and then creating
// through it are two separate resolutions of the same string, and a workspace
// owner can swap an ancestor for a junction in the gap between them: setup
// verifies, the attacker swaps, setup creates, and the object lands outside the
// approved tree. Checking again afterwards is too late, because the thing has
// already been created somewhere it should not be.
//
// A HANDLE pins the object rather than the name. Once a directory is open, that
// handle keeps referring to the same directory however the path is later
// rearranged, so creating a child relative to it cannot be redirected. os.Mkdir,
// os.OpenFile and os.RemoveAll are pathname-based by construction with no
// relative form on Windows, which is why this drops to NtCreateFile with
// OBJECT_ATTRIBUTES.RootDirectory.
// IO_STATUS_BLOCK.Information values for a create/open, named because "2 means
// it was created" is not something a reader should have to look up.
const (
windowsFileOpened uintptr = 1
windowsFileCreated uintptr = 2
)
// windowsACLDirectoryShare is the share mode every open here uses. A sandbox
// tree is live, so refusing to share would fail on any directory something else
// happens to have open: a denial of service on ourselves rather than a security
// property.
const windowsACLDirectoryShare = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE
// windowsFileIdentity is the kernel's own answer to "is this the same object",
// independent of what it is currently called.
//
// It exists because rollback cannot hold the anchor handle open for the whole
// sandbox lifetime (see rollbackWindowsACLSnapshots), so it has to re-open the
// anchor by pathname, and a pathname can be made to name a different object.
// Crucially that substitution needs NO reparse point: rename the real directory
// aside and create an ordinary directory wearing its name, and every no-follow
// check still passes because nothing anywhere is a link. Comparing the volume
// and file index catches it, because those identify the object the kernel
// actually opened.
type windowsFileIdentity struct {
Volume uint32
IndexHigh uint32
IndexLow uint32
}
func (identity windowsFileIdentity) empty() bool {
return identity == windowsFileIdentity{}
}
// windowsIdentityOfHandle reads the identity of an already-open object.
func windowsIdentityOfHandle(handle windows.Handle) (windowsFileIdentity, error) {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return windowsFileIdentity{}, fmt.Errorf("read windows file identity: %w", err)
}
return windowsFileIdentity{
Volume: info.VolumeSerialNumber,
IndexHigh: info.FileIndexHigh,
IndexLow: info.FileIndexLow,
}, nil
}
// openWindowsACLDirectoryNoFollow opens an existing directory by pathname,
// refusing to traverse or land on a reparse point.
//
// This is the ANCHOR for a handle-relative walk: the one pathname resolution
// that has to happen, with everything below it relative to the handle it
// returns. FILE_FLAG_OPEN_REPARSE_POINT stops the final component being
// followed, and verifyWindowsACLTargetNotRedirected then confirms no ancestor
// redirected either, because GetFinalPathNameByHandle answers for the whole
// resolved path.
func openWindowsACLDirectoryNoFollow(path string) (windows.Handle, error) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, fmt.Errorf("encode windows ACL directory %s: %w", path, err)
}
handle, err := windows.CreateFile(
utf16Path,
windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
windowsACLDirectoryShare,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
// Errno.Is maps the not-found codes to os.ErrNotExist, so a caller
// walking up to find the deepest existing ancestor keeps working.
return 0, fmt.Errorf("open windows ACL directory %s: %w", path, err)
}
if err := verifyWindowsACLHandleIsCleanDirectory(handle, path); err != nil {
_ = windows.CloseHandle(handle)
return 0, err
}
return handle, nil
}
// openWindowsACLDirectoryNoFollowWithIdentity is the anchor open plus the
// identity a later rollback needs in order to prove it re-opened the same
// object.
func openWindowsACLDirectoryNoFollowWithIdentity(path string) (windows.Handle, windowsFileIdentity, error) {
handle, err := openWindowsACLDirectoryNoFollow(path)
if err != nil {
return 0, windowsFileIdentity{}, err
}
identity, err := windowsIdentityOfHandle(handle)
if err != nil {
_ = windows.CloseHandle(handle)
return 0, windowsFileIdentity{}, fmt.Errorf("identify windows ACL directory %s: %w", path, err)
}
return handle, identity, nil
}
// reopenWindowsACLDirectoryAsIdentity re-opens an anchor by pathname and refuses
// it unless the kernel says it is the object that was opened before.
//
// Used only by rollback. See windowsFileIdentity for why the pathname alone is
// not enough, and rollbackWindowsACLSnapshots for why a handle cannot simply be
// held instead.
func reopenWindowsACLDirectoryAsIdentity(path string, want windowsFileIdentity) (windows.Handle, error) {
handle, err := openWindowsACLDirectoryNoFollow(path)
if err != nil {
return 0, err
}
if want.empty() {
return handle, nil
}
got, err := windowsIdentityOfHandle(handle)
if err != nil {
_ = windows.CloseHandle(handle)
return 0, fmt.Errorf("identify windows ACL directory %s: %w", path, err)
}
if got != want {
_ = windows.CloseHandle(handle)
return 0, fmt.Errorf("refusing to unwind under %s: it is no longer the directory setup created into, so something replaced it since (possible path swap during elevated setup)", path)
}
return handle, nil
}
// createWindowsACLChildDirectory creates one directory directly beneath parent,
// or opens it when it already exists, and reports which happened.
//
// name must be a single component; see validateWindowsACLComponent for why that
// is enforced rather than assumed. The kernel resolves it relative to the parent
// HANDLE, so nothing above it is consulted and nothing above it can be swapped
// underneath us.
//
// created is true only when this call made the directory, which the rollback
// needs: removing one that already existed would delete a user's data over a
// failure that had nothing to do with it.
func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, created bool, err error) {
if err := validateWindowsACLComponent(name); err != nil {
return 0, false, err
}
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, false, fmt.Errorf("encode windows ACL directory component %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
var status windows.IO_STATUS_BLOCK
if err := windows.NtCreateFile(
&handle,
windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE|windows.DELETE,
&attributes,
&status,
nil,
windows.FILE_ATTRIBUTE_DIRECTORY,
windowsACLDirectoryShare,
windows.FILE_OPEN_IF,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
0,
0,
); err != nil {
return 0, false, fmt.Errorf("create windows ACL directory component %s: %w", name, err)
}
// FILE_OPEN_IF means an EXISTING child is opened rather than created, and
// FILE_OPEN_REPARSE_POINT means a junction is opened AS the junction. Without
// this check that junction becomes the parent of the next level and every
// create beneath it lands wherever it points, which is the mid-walk swap this
// whole file exists to stop.
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
_ = windows.CloseHandle(handle)
return 0, false, err
}
return handle, status.Information == windowsFileCreated, nil
}
// openWindowsACLChildDirectory opens an EXISTING directory beneath parent and
// never creates one.
//
// Rollback walks back down the chain it created, and it must not conjure a
// component that has since been removed: FILE_OPEN_IF would recreate it, and
// then the unwind would delete a directory setup never made. FILE_OPEN is the
// whole difference from createWindowsACLChildDirectory.
func openWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, err error) {
if err := validateWindowsACLComponent(name); err != nil {
return 0, err
}
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, fmt.Errorf("encode windows ACL directory component %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
// Ask for the least that still allows passing through and checking the
// reparse attribute. This runs during rollback, on directories whose ACEs
// have already been applied, so every extra right is another way for the
// unwind to be refused by the very ACL it is unwinding. Notably absent:
// SYNCHRONIZE, and with it FILE_SYNCHRONOUS_IO_NONALERT, since nothing is
// read or written through this handle.
var status windows.IO_STATUS_BLOCK
if err := windows.NtCreateFile(
&handle,
windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES,
&attributes,
&status,
nil,
windows.FILE_ATTRIBUTE_DIRECTORY,
windowsACLDirectoryShare,
windows.FILE_OPEN,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
); err != nil {
return 0, fmt.Errorf("open windows ACL directory component %s: %w", name, err)
}
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
_ = windows.CloseHandle(handle)
return 0, err
}
return handle, nil
}
// createWindowsACLChildFile creates an empty file directly beneath parent, or
// opens it when it already exists, and reports which happened.
//
// The counterpart to createWindowsACLChildDirectory for the one materialized
// target that must be a FILE: .git/config, where creating a directory instead
// would break `git init` outright rather than merely mis-ACL it.
//
// FILE_OPEN_IF rather than FILE_CREATE deliberately. The pathname version this
// replaces used O_CREATE|O_EXCL and then tolerated os.ErrExist, so a racing
// creator winning was fine. FILE_CREATE's collision status is
// STATUS_OBJECT_NAME_COLLISION, which errors.Is(err, os.ErrExist) does NOT
// match, so porting it literally would have turned that tolerated race into a
// hard failure. FILE_OPEN_IF keeps the old behaviour and reports the truth in
// created, which rollback needs so it never deletes a file it did not make.
func createWindowsACLChildFile(parent windows.Handle, name string) (created bool, err error) {
if err := validateWindowsACLComponent(name); err != nil {
return false, err
}
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return false, fmt.Errorf("encode windows ACL file component %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
var handle windows.Handle
var status windows.IO_STATUS_BLOCK
if err := windows.NtCreateFile(
&handle,
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
&attributes,
&status,
nil,
windows.FILE_ATTRIBUTE_NORMAL,
windowsACLDirectoryShare,
windows.FILE_OPEN_IF,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
0,
0,
); err != nil {
return false, fmt.Errorf("create windows ACL file component %s: %w", name, err)
}
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
_ = windows.CloseHandle(handle)
return false, err
}
createdNow := status.Information == windowsFileCreated
if err := windows.CloseHandle(handle); err != nil {
return createdNow, fmt.Errorf("close windows ACL file component %s: %w", name, err)
}
return createdNow, nil
}
// deleteWindowsACLChildDirectory removes one directory directly beneath parent.
//
// The counterpart to the create above, and the reason rollback cannot use
// os.RemoveAll: that takes a pathname, so an ancestor swapped to a junction
// AFTER the object was created sends the recursive delete somewhere else and
// takes unrelated trees with it. Resolving relative to the parent handle makes
// that impossible.
//
// A missing child is not an error: rollback runs on failure paths where the
// object may never have been created.
func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error {
return deleteWindowsACLChild(parent, name, true)
}
// deleteWindowsACLChildFile removes one file directly beneath parent, for the
// materialized .git/config carveout. Rollback picks between this and the
// directory form from the shape recorded at materialization time rather than by
// stat-ing the pathname, because a stat is another pathname resolution and this
// whole file exists to avoid those.
func deleteWindowsACLChildFile(parent windows.Handle, name string) error {
return deleteWindowsACLChild(parent, name, false)
}
// deleteWindowsACLChild opens one child relative to parent and deletes it by
// SETTING ITS DISPOSITION, not with FILE_DELETE_ON_CLOSE.
//
// That distinction is the whole point, and it was measured rather than assumed.
// FILE_DELETE_ON_CLOSE defers the removal to cleanup, where a non-empty
// directory makes it fail with nothing to report it to: NtCreateFile returns
// success, CloseHandle returns success, and the directory is still there. A
// rollback built on it would report success while leaving materialized state on
// disk, which is strictly worse than the os.RemoveAll it replaces, because
// os.RemoveAll at least removed it.
//
// NtSetInformationFile answers synchronously and to the caller, so a non-empty
// directory comes back as STATUS_DIRECTORY_NOT_EMPTY. Leaving residue is
// acceptable, since the alternative is a recursive delete through a pathname an
// attacker may control; lying about having removed it is not.
//
// FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE also make the open refuse an
// object of the wrong shape rather than deleting it.
func deleteWindowsACLChild(parent windows.Handle, name string, directory bool) error {
if err := validateWindowsACLComponent(name); err != nil {
return err
}
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return fmt.Errorf("encode windows ACL component %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
shapeOption := uint32(windows.FILE_NON_DIRECTORY_FILE)
shapeAttribute := uint32(windows.FILE_ATTRIBUTE_NORMAL)
if directory {
shapeOption = windows.FILE_DIRECTORY_FILE
shapeAttribute = windows.FILE_ATTRIBUTE_DIRECTORY
}
// DELETE alone. Asking for SYNCHRONIZE as well would make this fail on any
// object already carrying a deny-read ACE, because FILE_GENERIC_READ and
// FILE_GENERIC_EXECUTE both include SYNCHRONIZE, and rollback exists
// precisely to undo objects that have just been ACL'd. Nothing is read or
// written through this handle, so synchronous IO is not needed either.
var handle windows.Handle
var status windows.IO_STATUS_BLOCK
if err := windows.NtCreateFile(
&handle,
windows.DELETE,
&attributes,
&status,
nil,
shapeAttribute,
windowsACLDirectoryShare,
windows.FILE_OPEN,
shapeOption|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
); err != nil {
if isWindowsNotExist(err) {
return nil
}
return fmt.Errorf("open windows ACL component %s for delete: %w", name, err)
}
defer func() { _ = windows.CloseHandle(handle) }()
// One BOOLEAN: FILE_DISPOSITION_INFORMATION.DeleteFile = TRUE.
disposition := byte(1)
var setStatus windows.IO_STATUS_BLOCK
if err := windows.NtSetInformationFile(
handle,
&setStatus,
&disposition,
uint32(unsafe.Sizeof(disposition)),
windows.FileDispositionInformation,
); err != nil {
return fmt.Errorf("delete windows ACL component %s: %w", name, err)
}
return nil
}
// rejectWindowsACLReparseHandle refuses a handle that landed on a reparse point.
//
// Deliberately NOT verifyWindowsACLTargetNotRedirected: that one compares the
// handle's resolved path against an expected pathname, which is exactly the
// pathname dependency the handle-relative walk removes. A child opened relative
// to a pinned parent is in the right place by construction even when the
// pathname no longer leads there, so comparing paths would reject correct, safe
// creates whenever the tree was legitimately renamed. The attribute is the only
// thing worth checking here.
func rejectWindowsACLReparseHandle(handle windows.Handle, name string) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect windows ACL component %s: %w", name, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("refusing to work through reparse-point component %s: possible path swap during elevated setup", name)
}
return nil
}
// verifyWindowsACLHandleIsCleanDirectory rejects a handle that landed on a
// reparse point, or on an object other than the path asked for.
func verifyWindowsACLHandleIsCleanDirectory(handle windows.Handle, path string) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect windows ACL directory %s: %w", path, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path)
}
return verifyWindowsACLTargetNotRedirected(handle, path)
}
// isWindowsNotExist reports a missing-object error from either side of the API.
// NtCreateFile returns NTSTATUS values, which do not map to os.ErrNotExist the
// way the Win32 error codes do.
func isWindowsNotExist(err error) bool {
if err == nil {
return false
}
if os.IsNotExist(err) {
return true
}
var status windows.NTStatus
if errors.As(err, &status) {
return status == windows.STATUS_OBJECT_NAME_NOT_FOUND || status == windows.STATUS_OBJECT_PATH_NOT_FOUND
}
return false
}
@@ -1,230 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"testing"
"golang.org/x/sys/windows"
)
// THE SWAP THAT PATHNAMES CANNOT SURVIVE.
//
// This is the race behind the materialization P1. A pathname-based create
// resolves the whole path again at the moment it runs, so an ancestor replaced
// between the verification and the create sends the new directory somewhere else
// entirely. Verifying afterwards is too late: the object already exists in the
// wrong place.
//
// A handle pins the OBJECT, not the name. This test performs the swap for real,
// in the window that used to be exploitable, and asserts the child still lands
// in the directory that was verified.
//
// Worth noting for anyone extending this: the fix is what makes the race
// testable at all. Against the old code the swap had to be threaded into the
// middle of a function; here it is three ordinary lines between an open and a
// create, because the handle is held across them.
func TestChildCreationFollowsTheHandleNotThePath(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "approved")
elsewhere := filepath.Join(root, "elsewhere")
for _, dir := range []string{approved, elsewhere} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("seed %s: %v", dir, err)
}
}
// Verified once, exactly as setup does before it materializes anything.
parent, err := openWindowsACLDirectoryNoFollow(approved)
if err != nil {
t.Fatalf("open approved directory: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
// THE SWAP, in the window that used to be exploitable: move the verified
// directory aside and leave a junction to somewhere else wearing its name.
moved := filepath.Join(root, "approved-moved")
if err := os.Rename(approved, moved); err != nil {
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
}
makeJunction(t, approved, elsewhere)
// The create resolves against the handle, so it must ignore the junction now
// sitting at the original pathname.
child, created, err := createWindowsACLChildDirectory(parent, "materialized")
if err != nil {
t.Fatalf("create child relative to the pinned handle: %v", err)
}
defer func() { _ = windows.CloseHandle(child) }()
if !created {
t.Error("created = false for a directory that did not exist")
}
if _, err := os.Stat(filepath.Join(moved, "materialized")); err != nil {
t.Errorf("the child did not land in the verified directory: %v", err)
}
if _, err := os.Stat(filepath.Join(elsewhere, "materialized")); err == nil {
t.Fatal("ESCAPED: the child was created through the junction, outside the approved tree")
}
}
// Materialization runs on trees that may already be half-built, so creating an
// existing directory has to be a no-op rather than a failure. created must still
// report the truth, because rollback deletes only what this call made.
func TestCreatingAnExistingChildOpensItInstead(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "already"), 0o700); err != nil {
t.Fatalf("seed: %v", err)
}
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
handle, created, err := createWindowsACLChildDirectory(parent, "already")
if err != nil {
t.Fatalf("open existing child: %v", err)
}
_ = windows.CloseHandle(handle)
if created {
t.Error("created = true for a directory that already existed; rollback would delete a user's data")
}
}
// The rollback counterpart. os.RemoveAll on a pathname whose ancestor has since
// become a junction recurses outside the workspace and deletes unrelated trees,
// which is the second P1. Resolving relative to the parent handle cannot.
func TestDeleteFollowsTheHandleNotThePath(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "approved")
elsewhere := filepath.Join(root, "elsewhere")
for _, dir := range []string{approved, elsewhere} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("seed %s: %v", dir, err)
}
}
// A bystander under the decoy: if the delete ever resolves by pathname it is
// reachable, and its survival is what proves the delete did not.
if err := os.Mkdir(filepath.Join(elsewhere, "victim"), 0o700); err != nil {
t.Fatalf("seed victim: %v", err)
}
if err := os.Mkdir(filepath.Join(approved, "victim"), 0o700); err != nil {
t.Fatalf("seed target: %v", err)
}
parent, err := openWindowsACLDirectoryNoFollow(approved)
if err != nil {
t.Fatalf("open approved directory: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
moved := filepath.Join(root, "approved-moved")
if err := os.Rename(approved, moved); err != nil {
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
}
makeJunction(t, approved, elsewhere)
if err := deleteWindowsACLChildDirectory(parent, "victim"); err != nil {
t.Fatalf("delete relative to the pinned handle: %v", err)
}
if _, err := os.Stat(filepath.Join(elsewhere, "victim")); err != nil {
t.Fatal("DESTRUCTIVE: rollback followed the junction and deleted a directory outside the approved tree")
}
if _, err := os.Stat(filepath.Join(moved, "victim")); err == nil {
t.Error("the directory inside the approved tree was not removed")
}
}
// A delete that cannot happen must SAY so.
//
// This is the gap that let a silent bug ship in the first version of this file.
// It deleted with FILE_DELETE_ON_CLOSE, which defers the removal to cleanup,
// where a non-empty directory makes it fail with nowhere to report it: the open
// returned success, the close returned success, and the directory was still
// there. The only test covering deletion used an EMPTY directory, so it passed
// throughout. Rollback built on that would have reported success while leaving
// materialized state on disk, which is worse than the os.RemoveAll it replaced,
// because os.RemoveAll actually removed it.
func TestDeletingANonEmptyDirectoryIsReported(t *testing.T) {
root := t.TempDir()
populated := filepath.Join(root, "populated")
if err := os.Mkdir(populated, 0o700); err != nil {
t.Fatalf("seed: %v", err)
}
if err := os.WriteFile(filepath.Join(populated, "occupant"), []byte("x"), 0o600); err != nil {
t.Fatalf("seed occupant: %v", err)
}
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
err = deleteWindowsACLChildDirectory(parent, "populated")
if _, statErr := os.Stat(populated); statErr != nil {
t.Fatalf("the directory was removed with its contents, which this delete must never do: %v", statErr)
}
if err == nil {
t.Fatal("reported success while leaving the directory in place")
}
}
// The directory form must refuse a file rather than delete it, and the file form
// must handle the one materialized target that is a file.
func TestDeleteDistinguishesFilesFromDirectories(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "config"), []byte("x"), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
if err := deleteWindowsACLChildDirectory(parent, "config"); err == nil {
t.Error("the directory delete accepted a file")
}
if err := deleteWindowsACLChildFile(parent, "config"); err != nil {
t.Fatalf("deleteWindowsACLChildFile: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "config")); err == nil {
t.Error("the file survived its delete")
}
}
// Rollback runs on failure paths where the object may never have been created,
// so a missing child is success rather than an error to report.
func TestDeletingAMissingChildIsNotAnError(t *testing.T) {
root := t.TempDir()
parent, err := openWindowsACLDirectoryNoFollow(root)
if err != nil {
t.Fatalf("open root: %v", err)
}
defer func() { _ = windows.CloseHandle(parent) }()
if err := deleteWindowsACLChildDirectory(parent, "never-existed"); err != nil {
t.Fatalf("deleting a missing child reported an error: %v", err)
}
}
// The anchor open is the one pathname resolution in the walk, so it has to
// refuse a junction itself rather than leaving it to a later check.
func TestOpeningAJunctionAnchorIsRefused(t *testing.T) {
root := t.TempDir()
real := filepath.Join(root, "real")
if err := os.Mkdir(real, 0o700); err != nil {
t.Fatalf("seed: %v", err)
}
link := filepath.Join(root, "link")
makeJunction(t, link, real)
handle, err := openWindowsACLDirectoryNoFollow(link)
if err == nil {
_ = windows.CloseHandle(handle)
t.Fatal("opened a junction as the materialization anchor; every create beneath it would land outside the approved tree")
}
}
@@ -1,161 +0,0 @@
//go:build windows
package sandbox
import (
"fmt"
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
// GetFinalPathNameByHandle flags. x/sys/windows does not export these.
const (
windowsFileNameNormalized uint32 = 0x0
windowsVolumeNameDOS uint32 = 0x0
)
// verifyWindowsACLTargetNotRedirected fails when an opened handle resolved
// somewhere other than the requested path, which means a component along the way
// is a reparse point.
//
// openWindowsACLTarget's FILE_FLAG_OPEN_REPARSE_POINT check covers the FINAL
// component only; CreateFile still resolves ANCESTORS. A user who controls the
// workspace can turn an ancestor — .git, say — into a junction before elevated
// setup runs, and setup would then apply its DACL change to an object outside
// the approved tree while the final-component check still passed. Junctions need
// no privilege to create, unlike symlinks, so this is reachable by exactly the
// unprivileged user the sandbox exists to contain.
//
// GetFinalPathNameByHandle answers where the handle actually landed, covering
// every component in one call rather than walking the path and re-checking each
// component (which would also race between the checks).
//
// The comparison basis must NEVER follow a reparse point, and getting that wrong
// is what made this guard mostly ornamental.
//
// It used to compare against canonicalSandboxWorkspaceRoot(path), which runs
// filepath.EvalSymlinks — the same resolution the kernel just performed. So for a
// directory SYMLINK the two sides agreed precisely BECAUSE the redirect happened:
// GetFinalPathNameByHandle followed the link to produce one side, EvalSymlinks
// followed the same link to produce the other, and elevated setup went on to
// rewrite the DACL of an object outside the workspace.
//
// Junctions were rejected, but by accident rather than by design. Go reports a
// junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks refuses it,
// canonicalSandboxWorkspaceRoot falls back to the lexical path, and the mismatch
// surfaces. That is a property of the standard library's mode bits, not of this
// check: a Go release that resolved mount points would silently disarm the one
// case this was known to catch.
//
// So the expected side is now normalized WITHOUT resolving anything. Casing and
// 8.3 short names still have to compare equal, since a legitimate target can be
// spelled either way and still be the same object — EqualFold covers the first
// and GetLongPathName the second, and neither follows a link.
//
// This does mean a target deliberately spelled through a symlink is now refused.
// That is the intended direction: the whole question here is whether the object
// the handle landed on is the object that was named, and a caller that wants a
// link's target should name the target.
func verifyWindowsACLTargetNotRedirected(handle windows.Handle, path string) error {
buffer := make([]uint16, windows.MAX_LONG_PATH)
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS)
if err != nil {
return fmt.Errorf("resolve windows ACL target %s: %w", path, err)
}
if int(n) < len(buffer) {
buffer = buffer[:n]
}
actual := trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer))
expected := trimWindowsExtendedPathPrefix(windowsACLComparablePath(path))
if !strings.EqualFold(filepath.Clean(actual), filepath.Clean(expected)) {
return fmt.Errorf("refusing to apply ACL to %s: it resolves to %s, so a parent directory is a reparse point (possible path swap during elevated setup)", path, actual)
}
return nil
}
// windowsACLComparablePath normalizes a path for comparison against where a
// handle actually landed, WITHOUT resolving any reparse point.
//
// The two things that must still compare equal are casing and 8.3 short names,
// because either can spell the same object. GetLongPathName expands the short
// form by reading directory entries; it does not follow a symlink or a junction
// to its target, which is the entire reason it is used here instead of
// EvalSymlinks. Casing is left to the caller's EqualFold.
//
// Every failure degrades to the lexically cleaned path rather than to a resolved
// one. A path this cannot expand is compared as written, which can only produce
// a spurious MISMATCH — a refusal — and never a spurious match. That is the
// direction a redirect check has to fail in.
func windowsACLComparablePath(path string) string {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" || cleaned == "." {
return cleaned
}
if absolute, err := filepath.Abs(cleaned); err == nil {
cleaned = absolute
}
wide, err := windows.UTF16PtrFromString(cleaned)
if err != nil {
return cleaned
}
buffer := make([]uint16, windows.MAX_LONG_PATH)
n, err := windows.GetLongPathName(wide, &buffer[0], uint32(len(buffer)))
if err != nil || n == 0 || int(n) >= len(buffer) {
return cleaned
}
return filepath.Clean(windows.UTF16ToString(buffer[:n]))
}
// trimWindowsExtendedPathPrefix strips the \?\ form GetFinalPathNameByHandle
// returns so it can be compared with an ordinary path.
func trimWindowsExtendedPathPrefix(path string) string {
// Built from filepath.Separator rather than written as literals so the
// backslashes cannot be miscounted by whatever writes this file.
sep := string(filepath.Separator)
devicePrefix := sep + sep + "?" + sep
uncPrefix := devicePrefix + "UNC" + sep
if strings.HasPrefix(path, uncPrefix) {
return sep + sep + strings.TrimPrefix(path, uncPrefix)
}
return strings.TrimPrefix(path, devicePrefix)
}
// verifyWindowsACLPathComponentNotRedirected opens one path component no-follow
// and refuses it if it is a reparse point or resolves anywhere other than its own
// pathname. Because GetFinalPathNameByHandle answers for the WHOLE resolved path,
// verifying a single existing component also clears every ancestor above it.
//
// A missing component surfaces as os.ErrNotExist so the caller can walk further
// up. Only FILE_READ_ATTRIBUTES is requested: this inspects, it never writes, and
// asking for more would fail on ancestors the setup process has no rights on.
func verifyWindowsACLPathComponentNotRedirected(path string) error {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode windows ACL path component %s: %w", path, err)
}
handle, err := windows.CreateFile(
utf16Path,
windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
// syscall.Errno.Is maps ERROR_FILE_NOT_FOUND/ERROR_PATH_NOT_FOUND to
// os.ErrNotExist, so the caller's errors.Is check keeps working.
return fmt.Errorf("open windows ACL path component %s: %w", path, err)
}
defer func() { _ = windows.CloseHandle(handle) }()
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect windows ACL path component %s: %w", path, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path)
}
return verifyWindowsACLTargetNotRedirected(handle, path)
}
@@ -1,79 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// An unprivileged user who controls the workspace can turn an ancestor of a
// configured ACL target into a junction before elevated setup runs. CreateFile
// resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the
// final-component check passes and setup would rewrite the DACL of an object
// outside the approved tree.
//
// Junctions, unlike symlinks, need no privilege — which is what makes this
// reachable by exactly the user the sandbox is containing.
func TestOpenWindowsACLTargetRefusesAJunctionAncestor(t *testing.T) {
base := t.TempDir()
workspace := filepath.Join(base, "workspace")
outside := filepath.Join(base, "OUTSIDE")
for _, dir := range []string{workspace, outside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
// The object the attacker wants setup to touch.
victim := filepath.Join(outside, "hooks")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatalf("mkdir victim: %v", err)
}
// .git is the ancestor, and it is a junction to OUTSIDE.
gitDir := filepath.Join(workspace, ".git")
if out, err := exec.Command("cmd", "/c", "mklink", "/J", gitDir, outside).CombinedOutput(); err != nil {
t.Skipf("cannot create a junction here: %v %s", err, out)
}
// This is the path setup would be configured with.
target := filepath.Join(gitDir, "hooks")
if _, err := os.Stat(target); err != nil {
t.Fatalf("precondition: the junction should make %s reachable: %v", target, err)
}
handle, _, err := openWindowsACLTarget(target)
if err == nil {
_ = windows.CloseHandle(handle)
t.Fatal("opened an ACL target through a junction ancestor; setup would have rewritten a DACL outside the workspace")
}
if !strings.Contains(err.Error(), "reparse point") {
t.Errorf("error = %v, want it to name the reparse point", err)
}
t.Logf("refused as expected: %v", err)
}
// The guard must not reject ordinary targets, including ones spelled
// non-canonically — a differently-cased path is the same object, not a redirect.
func TestOpenWindowsACLTargetAcceptsAnOrdinaryTarget(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "Nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
for _, spelling := range []string{nested, strings.ToLower(nested)} {
handle, isDir, err := openWindowsACLTarget(spelling)
if err != nil {
t.Fatalf("openWindowsACLTarget(%q): %v", spelling, err)
}
if !isDir {
t.Errorf("%q reported as not a directory", spelling)
}
_ = windows.CloseHandle(handle)
}
}
@@ -1,148 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// THE RESTORE MUST LAND ON THE OBJECT THE DACL CAME FROM.
//
// Creation and the materialization unwind were both bound to handles, and the
// restore branch was left re-resolving the target by pathname. Its comment
// justified that with a no-follow re-open, which rules out a REPARSE POINT
// swapped in since apply and nothing else. The other substitution passes it
// untouched: rename the target aside and put an ordinary directory of the same
// name in its place. Nothing in that decoy is a link, so the no-follow open
// succeeds, the pre-apply DACL is written onto the attacker's object, and the
// real one keeps the ACEs from the setup that just aborted.
func TestRollbackRefusesToRestoreOntoAReplacedTarget(t *testing.T) {
root := t.TempDir()
approved := filepath.Join(root, "ws")
target := filepath.Join(approved, "target")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("seed the approved tree: %v", err)
}
handle, _, err := openWindowsACLTarget(target)
if err != nil {
t.Fatalf("open the target: %v", err)
}
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
_ = windows.CloseHandle(handle)
t.Fatalf("read the target DACL: %v", err)
}
identity, err := windowsIdentityOfHandle(handle)
if err != nil {
_ = windows.CloseHandle(handle)
t.Fatalf("read the target identity: %v", err)
}
_ = windows.CloseHandle(handle)
// The swap, exactly as it would happen between apply and rollback. Ordinary
// directories throughout: no links, no reparse points.
if err := os.Rename(target, target+"-moved"); err != nil {
t.Skipf("cannot rename here: %v", err)
}
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatalf("plant the replacement: %v", err)
}
snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity}
err = rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot})
if err == nil {
t.Fatal("rollback wrote the pre-apply DACL onto a different object wearing the target's name")
}
if !strings.Contains(err.Error(), "no longer the object") {
t.Errorf("refused for the wrong reason: %v", err)
}
}
// And an untouched target still restores, or the guard above would be satisfied
// by a rollback that refuses everything.
func TestRollbackRestoresAnUnchangedTarget(t *testing.T) {
target := filepath.Join(t.TempDir(), "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatalf("seed the target: %v", err)
}
handle, _, err := openWindowsACLTarget(target)
if err != nil {
t.Fatalf("open the target: %v", err)
}
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
_ = windows.CloseHandle(handle)
t.Fatalf("read the target DACL: %v", err)
}
identity, err := windowsIdentityOfHandle(handle)
if err != nil {
_ = windows.CloseHandle(handle)
t.Fatalf("read the target identity: %v", err)
}
_ = windows.CloseHandle(handle)
snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity}
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
t.Fatalf("an unchanged target was refused: %v", err)
}
}
// THE COMMENT AT rollbackWindowsACLSnapshots CLAIMED A TEST THAT DID NOT EXIST.
//
// It said "TestRollbackUnwindsDescendantsBeforeAncestors pins it" about the
// reverse iteration order, and grepping the repo found only that sentence. The
// ordering is load-bearing twice over: a materialized directory must be empty
// before its own removal is attempted, and SetSecurityInfo propagates
// inheritable ACEs downward so the ancestor has to be restored last. Neither
// was pinned by anything.
func TestRollbackUnwindsDescendantsBeforeAncestors(t *testing.T) {
root := t.TempDir()
ancestor := filepath.Join(root, "ws")
descendant := filepath.Join(ancestor, "child")
if err := os.MkdirAll(descendant, 0o700); err != nil {
t.Fatalf("seed the tree: %v", err)
}
snapshotFor := func(path string) windowsACLSnapshot {
t.Helper()
handle, _, err := openWindowsACLTarget(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer windows.CloseHandle(handle)
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Fatalf("read the DACL for %s: %v", path, err)
}
identity, err := windowsIdentityOfHandle(handle)
if err != nil {
t.Fatalf("read the identity for %s: %v", path, err)
}
return windowsACLSnapshot{Path: path, Descriptor: descriptor, TargetID: identity}
}
// Ascending by path key, the order applyWindowsACLPlan produces.
snapshots := []windowsACLSnapshot{snapshotFor(ancestor), snapshotFor(descendant)}
var order []string
restore := windowsACLRestoreHook
windowsACLRestoreHook = func(path string) { order = append(order, path) }
t.Cleanup(func() { windowsACLRestoreHook = restore })
if err := rollbackWindowsACLSnapshots(snapshots); err != nil {
t.Fatalf("rollbackWindowsACLSnapshots: %v", err)
}
if len(order) != 2 {
t.Fatalf("restored %d targets, want 2: %v", len(order), order)
}
if order[0] != descendant || order[1] != ancestor {
t.Errorf("restore order was %v; the descendant must be restored before its ancestor, because SetSecurityInfo propagates inheritable ACEs downward", order)
}
}
@@ -1,113 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// THE COMPARISON BASIS MUST NOT FOLLOW WHAT IT IS CHECKING FOR.
//
// verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where the
// handle landed and compares that against the requested path. It used to build
// the second side with filepath.EvalSymlinks, which is the same resolution the
// kernel had just performed, so for a directory symlink the two sides agreed
// BECAUSE the redirect happened, and elevated setup rewrote the DACL of an
// object outside the workspace.
//
// Junctions were caught, but only because Go reports one as ModeIrregular and
// EvalSymlinks declines it, leaving the lexical path to disagree. That is a
// standard-library detail, not a property of the guard.
// The property, testable with no privilege at all: the normalizer must not
// resolve a reparse point.
//
// HONEST LIMIT, because it would be easy to read more into this than it proves.
// A junction is used because creating one needs no privilege, and EvalSymlinks
// does not resolve a junction either — so this test passes against BOTH the old
// basis and the new one, and reverting the fix does not fail it. It is an
// invariant test, not a regression for this change: it pins that normalization
// leaves a reparse point alone, which is what would break first if a future Go
// started resolving mount points and silently disarmed the junction case.
//
// The case that actually separates the two bases is the directory symlink below,
// and that one skips without privilege. On this machine it skipped.
func TestACLComparablePathDoesNotResolveAReparsePoint(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(base, "outside")
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
t.Fatalf("mkdir outside: %v", err)
}
link := filepath.Join(base, "link")
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, outside).CombinedOutput(); err != nil {
t.Skipf("cannot create a junction here: %v (%s)", err, out)
}
through := filepath.Join(link, "hooks")
comparable := windowsACLComparablePath(through)
// Compared against the normalizer's OWN answer for each path, never against
// the raw input. GetLongPathName legitimately rewrites the string: on a CI
// runner the temp directory is an 8.3 short name, so expanding RUNNER~1 to
// runneradmin makes the result differ from what was passed in while naming
// exactly the same object. An earlier version of this test asserted equality
// with the input and failed on Windows CI for that reason, which was the test
// being wrong rather than the code.
target := windowsACLComparablePath(filepath.Join(outside, "hooks"))
// The whole property: normalizing a path THROUGH the reparse point must not
// produce the target's normalization. If it did, the guard would be comparing
// a redirected handle against a redirected expectation and matching itself.
if strings.EqualFold(filepath.Clean(comparable), filepath.Clean(target)) {
t.Errorf("windowsACLComparablePath resolved through the reparse point:\n through %s\n target %s", comparable, target)
}
// And it still has to name the link component, or it resolved to something
// else entirely rather than leaving the path alone.
if !strings.Contains(strings.ToLower(comparable), "link") {
t.Errorf("windowsACLComparablePath lost the link component: %s", comparable)
}
// The old basis is checked alongside for contrast: where it resolves, it
// could not have been the comparison basis.
if resolved := canonicalSandboxWorkspaceRoot(through); strings.EqualFold(filepath.Clean(resolved), filepath.Clean(filepath.Join(outside, "hooks"))) {
t.Logf("confirmed: canonicalSandboxWorkspaceRoot resolves %s to %s, which is why it cannot be the basis", through, resolved)
}
}
// The end-to-end symlink case the finding names. Directory symlinks need either
// Developer Mode or SeCreateSymbolicLinkPrivilege, so this skips where it cannot
// build the fixture rather than passing vacuously.
func TestOpenWindowsACLTargetRefusesASymlinkAncestor(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(base, "outside")
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
t.Fatalf("mkdir outside: %v", err)
}
workspace := filepath.Join(base, "workspace")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
// A DIRECTORY symlink, not a junction: os.Symlink maps to CreateSymbolicLinkW
// and the directory flag is inferred from the existing target.
link := filepath.Join(workspace, ".git")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("cannot create a directory symlink here (needs Developer Mode or SeCreateSymbolicLinkPrivilege): %v", err)
}
target := filepath.Join(link, "hooks")
handle, _, err := openWindowsACLTarget(target)
if err == nil {
_ = windows.CloseHandle(handle)
t.Fatal("elevated setup opened a target through a symlinked ancestor; its DACL change would land outside the workspace")
}
if !strings.Contains(err.Error(), "reparse point") {
t.Errorf("refusal does not name the cause: %v", err)
}
}
+2 -11
View File
@@ -73,19 +73,10 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) {
if err != nil {
t.Fatalf("BuildWindowsACLPlan: %v", err)
}
// Two deny entries, one per SID the token can carry here. ReadOnly is the
// only capability a profile with no write roots gets, and ReadAllow is the
// read grant a DenyRead profile's strict token carries: denying just one of
// them leaves the carveout readable through the other.
if len(plan.Entries) != 2 {
t.Fatalf("ACL entries = %#v, want a deny-read entry per capability SID", plan.Entries)
if len(plan.Entries) != 1 {
t.Fatalf("ACL entries = %#v, want one deny-read entry", plan.Entries)
}
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true)
readSID, err := WindowsReadAllowSID(home)
if err != nil {
t.Fatalf("WindowsReadAllowSID: %v", err)
}
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, readSID, true)
}
func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) {
@@ -1,87 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ELEVATED SETUP MUST NOT CREATE THROUGH A JUNCTION THE CALLER PLANTED.
//
// ensureWindowsSandboxRuntimeCandidates runs as Administrator, and os.MkdirAll
// follows links. Both candidate ancestries are prepared by the invoking user, so
// a junction at a not-yet-created component below the cache directory — or at the
// fallback anchor — made elevated setup build the runtime tree beneath a
// redirected, Administrator-writable target. The ACL applier's no-follow check
// runs later and spots the redirection, but the privileged create has already
// happened by then, outside its rollback boundary.
//
// A junction needs no privilege on Windows, so this reproduces the caller's half
// of the race on an ordinary unelevated box.
func TestRuntimeCandidateCreationRefusesAJunctionedComponent(t *testing.T) {
cacheRoot := t.TempDir()
target := t.TempDir()
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
workspace := t.TempDir()
candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
if !ok {
t.Fatalf("SETUP INVALID: no deterministic candidate for %s under %s", workspace, cacheRoot)
}
// The component the caller controls: the first one Zero owns, below the
// operator's cache directory.
owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")
if !strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(owned)) {
t.Fatalf("SETUP INVALID: candidate %s does not sit under the owned component %s", candidate, owned)
}
if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil {
t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out)
}
err := ensureRuntimeCandidateDir(candidate)
if err == nil {
t.Fatal("elevated setup created the runtime tree through a junction the caller planted")
}
// The whole point: nothing may appear beneath the redirected target.
entries, readErr := os.ReadDir(target)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Fatalf("setup created %v beneath the redirected target", names)
}
}
// And an ordinary cache directory still gets its candidate, or the refusal above
// would be satisfied by a creator that refuses everything.
func TestRuntimeCandidateCreationStillCreatesAnOrdinaryTree(t *testing.T) {
cacheRoot := t.TempDir()
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
workspace := t.TempDir()
candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
if !ok {
t.Fatalf("SETUP INVALID: no deterministic candidate for %s", workspace)
}
if err := ensureRuntimeCandidateDir(candidate); err != nil {
t.Fatalf("an ordinary cache tree was refused: %v", err)
}
info, err := os.Stat(candidate)
if err != nil || !info.IsDir() {
t.Fatalf("the candidate was not created: err=%v", err)
}
}
@@ -3,11 +3,8 @@
package sandbox
import (
"errors"
"fmt"
"io"
"os"
"strings"
)
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
@@ -17,15 +14,6 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
// The marker attests to a PLAN. This attests to the OBJECT that plan was
// applied to, which cleanup can reclaim and an ordinary run then recreates
// without the capability ACE. Checked before the token is minted, so the
// operator is told to rerun setup instead of watching every sandboxed write
// fail with a bare access-denied.
if err := verifyWindowsRuntimeRootCapability(config); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
case WindowsSandboxLevelUnelevated:
if err := ensureWindowsUnelevatedSetup(config); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
@@ -71,7 +59,7 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
// granting only the user, Administrators, and SYSTEM (msys2-runtime
// sigproc.cc sigproc_init -> sec_user_nih -> __sec_user), and a
// WRITE_RESTRICTED write check must ALSO match one of the token's
// restricted SIDs (logon SID and capability SIDs). None of the
// restricted SIDs (logon SID, Everyone, capability SIDs). None of the
// granted SIDs can be added to the restricted list without collapsing the
// write jail (each has write access nearly everywhere), so MSYS startup
// dies with "couldn't create signal pipe" or "CreateFileMapping <SID>.1",
@@ -87,124 +75,6 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
// reads under that flag (#612). Profiles with DenyRead keep the fully
// restricted token, trading spawn capability for read-deny enforcement.
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0
// That strict token then needs the read capability in its SID list, because
// the restricted-SID check covers reads once WRITE_RESTRICTED is gone. See
// windowsRestrictedTokenSIDsForProfile.
tokenSIDs, err = windowsRestrictedTokenSIDsForProfile(tokenSIDs, config.SandboxHome, writeRestricted)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
// A provisioned sandbox principal replaces the restricted token entirely: it
// is a separate account, so reads outside its granted roots are denied by the
// filesystem rather than left open the way a same-user restricted token has
// to leave them (#662). Absent, unprovisioned or opted-out, ok is false and
// the restricted-token backend below runs exactly as before.
principalToken, ok, err := windowsSandboxPrincipalToken(config)
if err != nil {
// The one path here that does not fall back, because a provisioned but
// unusable principal means the sandbox is broken rather than absent. Say
// how to get out of it, since the whole backend is opt-in.
fmt.Fprintf(stderr, "%s: sandbox principal is provisioned but unusable: %v. Re-run `zero sandbox setup` from an elevated terminal, or unset %s to fall back to the restricted-token sandbox.\n",
WindowsSandboxCommandRunnerName, err, windowsSandboxIdentityEnv)
return 1
}
if ok {
defer principalToken.Close()
// The principal gets its own identity AND the write jail, not one or the
// other. Its ACEs confine reads; without the restricted token it would
// still hold every write its ambient memberships grant, so a profile
// permitting writes only to the workspace could still write anywhere
// BATCH or BUILTIN\Users may — C:\Users\Public\Documents, for one.
//
// The principal's own SID is deliberately NOT a restricting SID, and that
// is the whole of the write jail on this path.
//
// WRITE_RESTRICTED allows a write only when BOTH the normal token and the
// restricting-SID list allow it. The account SID is already enabled in the
// normal token, so listing it as a restricting SID makes the second check
// a formality for anything granted to that account: every path carrying a
// direct principal ACE passes both halves and is writable wherever it
// sits. The principal's own profile directory, which Windows creates on
// first logon with exactly such an ACE, is outside every configured write
// root and was writable for that reason.
//
// Same defect as the World SID in the restricted list (#865), with the
// account SID in place of Everyone: a SID already carried by the normal
// token cannot also serve as the restriction on it.
//
// Confining to the capability SIDs alone restores the intersection the
// jail is supposed to be. A configured write root carries BOTH a principal
// ACE, from the principal plan, satisfying the normal token, AND a
// capability ACE, from BuildWindowsACLPlan which setup applies on every
// path, satisfying the restriction. So the principal keeps the tree it
// owns, while anything holding only a principal ACE now fails the
// restricted check.
principalUser, err := principalToken.GetTokenUser()
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": read sandbox principal SID: "+err.Error())
return 1
}
jailSIDs := windowsPrincipalJailSIDs(tokenSIDs, principalUser.User.Sid.String())
// A strict token restricts READS too, and the account SID that carries the
// read roots was just removed above. Without the read capability the jail
// stops being a write jail and becomes a total one: no read root, no
// executable, nothing. BuildWindowsACLPlan grants this same SID on every
// read root and denies it on every DenyRead path, so the allow-list and the
// restriction stay one decision.
if !writeRestricted {
readSID, err := WindowsReadAllowSID(config.SandboxHome)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": resolve sandbox read capability: "+err.Error())
return 1
}
jailSIDs = append(jailSIDs, readSID)
}
jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
defer jailedToken.Close()
// CreateProcessAsUser below is about to be handed a token for a DIFFERENT
// account, which forfeits the own-token exemption the ordinary restricted
// path relies on. Enable what that needs, and refuse with a specific reason
// when this process cannot, rather than letting an unelevated run surface a
// bare "Access is denied" that reads as the command being rejected.
//
// Refusing rather than falling back: an operator who set the opt-in to
// confine reads must not be handed the same-user restricted token while
// believing otherwise.
if err := enableWindowsPrincipalLaunchPrivileges(); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
// Stop the child describing the CALLER. Everything identifying the account
// survived from the invoking user's environment, so a principal command
// resolved its per-user state through paths inside a profile it cannot
// open. Applied here, on the principal path only, because this is the first
// point that knows which account the command is about to run as.
// The role has to be the one the token above was minted for, or the child
// is told it is the other account and resolves its per-user state under a
// profile it cannot open.
config.Env = windowsPrincipalIdentityEnvironment(
config.Env,
windowsSandboxUserName(
windowsSandboxPrincipalKey(config),
windowsSandboxRoleForNetwork(config.PermissionProfile.Network.Mode),
),
config.PermissionProfile.Runtime,
)
exitCode, err := runWindowsCommandAsUser(jailedToken, config)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
return exitCode
}
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
@@ -241,111 +111,9 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
if marker.contains(applied) {
return nil
}
// THE TIER BOUNDARY, STATED BEFORE ANY MUTATION IS ATTEMPTED.
//
// One plan is consumed by two tiers with different authority. A profile that
// carries DenyRead runs on a strict token, and the strict token applies the
// restricted-SID check to READS, so the read capability has to be granted
// wherever the command reads from -- including the volume root that
// permissionProfileReadRoots seeds. Elevated setup can write that DACL. An
// ordinary user cannot, and the common opener asks for WRITE_DAC on every
// entry, so this tier fails on that one root every time.
//
// Dropping the root ACE instead is not an option: the strict token would then
// fail its own read check for the executable and every ambient read. So the
// tier is refused explicitly, naming the root and the reason, rather than
// discovered as an ACCESS_DENIED after the fact. The real smoke test misses
// this because it substitutes a user-owned temporary directory for the
// production read root.
if root := windowsPlanVolumeRootGrant(plan); root != "" {
return fmt.Errorf("unelevated sandbox setup cannot grant the read capability at the volume root %s, which this profile needs because it configures denyRead and therefore runs on a fully restricted token: "+
"changing that directory's permissions requires Administrator rights. "+
"Run `zero sandbox setup` from an elevated (Administrator) terminal, "+
"or remove denyRead from the sandbox configuration so the command can run on a write-restricted token instead", root)
}
if _, err := applyWindowsACLPlan(plan); err != nil {
// Refusing to run is right: without these ACEs the write jail does not
// exist, so continuing would run the command believing it is sandboxed
// when it is not. What was wrong was the diagnosis. Every failure got the
// same "the workspace may be on a filesystem you do not own" guess, and
// the suggested remedy was elevated setup, which does not help at all
// when the real problem is one root in the plan that nobody can ACL.
//
// Being precise matters because this failure repeats: the success marker
// is only recorded on success, so the same plan fails identically on
// every later command until the offending root leaves it. A reader who
// cannot tell which root is at fault has no way out of that.
// Every remedy named below is one the reader can actually carry out.
// Both messages used to offer `--sandbox forbid`, which is not an option
// at all: SandboxPreferenceForbid is an internal engine state with no flag
// behind it, so acting on it produced an unknown option and left the reader
// stuck on the failure they had just been told how to clear. Advice that
// does not work costs more than none, since finding that out takes time.
if denied := windowsACLPlanDeniedPath(err); denied != "" {
return fmt.Errorf("apply unelevated workspace ACLs: %w; %s cannot have its permissions changed by this user, "+
"so the sandbox cannot enforce a write boundary there and will not run the command. "+
"That path is one of this workspace's sandbox roots, usually a system directory that arrived via TEMP or TMP. "+
"Check those, or turn the sandbox off in your user config with "+
`"sandbox": {"enabled": false}. `+
"Running `zero sandbox setup` elevated will NOT fix this", err, denied)
}
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
"run `zero sandbox setup` from an elevated (Administrator) terminal, "+
`or turn the sandbox off in your user config with "sandbox": {"enabled": false}`, err)
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
}
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
}
// windowsACLPlanDeniedPath pulls the target path out of an apply failure that
// was an access denial, and returns "" for anything else.
//
// applyWindowsACLPathGroup already wraps the path into its error, so this reads
// the message rather than threading a typed error through four layers for one
// diagnostic. The string it matches is produced in the same package by
// openWindowsACLTarget, and a test pins the pairing so the two cannot drift
// apart silently.
func windowsACLPlanDeniedPath(err error) string {
if err == nil || !errors.Is(err, os.ErrPermission) {
return ""
}
const marker = "open windows ACL target "
message := err.Error()
start := strings.Index(message, marker)
if start < 0 {
return ""
}
// Colon-SPACE, not colon. The wrapper is "...target %s: %w", and on Windows
// the path itself starts with a drive colon, so splitting on the first colon
// returns "C". A drive colon is always followed by a separator, never a
// space, which makes ": " the only unambiguous boundary here.
rest := message[start+len(marker):]
end := strings.Index(rest, ": ")
if end <= 0 {
return ""
}
return strings.TrimSpace(rest[:end])
}
// windowsPrincipalJailSIDs returns the restricting SIDs for a principal command.
//
// A named function with its own test rather than an inline slice literal,
// because the defect it encodes was invisible: the production composition added
// the account's own SID while the only jail test granted a GROUP, so the
// configuration that shipped was never the configuration under test.
//
// accountSID is taken and DELIBERATELY EXCLUDED rather than simply not passed.
// Naming it here makes the exclusion the function's stated contract instead of an
// omission a later edit could undo without noticing, and it also removes the SID
// should it ever arrive through capabilitySIDs, which is the same route the World
// SID took in #865.
func windowsPrincipalJailSIDs(capabilitySIDs []string, accountSID string) []string {
account := strings.TrimSpace(accountSID)
jail := make([]string, 0, len(capabilitySIDs))
for _, sid := range capabilitySIDs {
if account != "" && strings.EqualFold(strings.TrimSpace(sid), account) {
continue
}
jail = append(jail, sid)
}
return jail
}
@@ -1,161 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"testing"
"golang.org/x/sys/windows"
)
// The outer rollback must only delete principals this run created.
//
// Dual-role setup provisions offline then online. If the offline role adopts an
// account that already existed and anything afterwards fails, the outer rollback
// used to delete it anyway, turning a partial re-setup failure into the loss of
// a principal that was working before the run started. Two roles make that the
// common case rather than an unlucky one: the first role usually succeeds, so
// there is nearly always something for a later failure to destroy.
func TestDualRoleSetupRollbackSparesAdoptedPrincipals(t *testing.T) {
for name, testCase := range map[string]struct {
createdByRole map[windowsSandboxRole]bool
wantRemoved []windowsSandboxRole
}{
"offline adopted, online created": {
createdByRole: map[windowsSandboxRole]bool{
windowsSandboxRoleOffline: false,
windowsSandboxRoleOnline: true,
},
// Only the one this run made. Deleting the adopted offline principal
// is the data loss this guards against.
wantRemoved: []windowsSandboxRole{windowsSandboxRoleOnline},
},
"both adopted": {
createdByRole: map[windowsSandboxRole]bool{
windowsSandboxRoleOffline: false,
windowsSandboxRoleOnline: false,
},
wantRemoved: nil,
},
"both created": {
createdByRole: map[windowsSandboxRole]bool{
windowsSandboxRoleOffline: true,
windowsSandboxRoleOnline: true,
},
wantRemoved: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline},
},
} {
t.Run(name, func(t *testing.T) {
var removed []windowsSandboxRole
restoreDualRoleSeams(t, testCase.createdByRole, &removed)
// Fail on the SECOND role, not the first. ACL application happens per
// role inside the loop, so failing the first aborts before the second
// is provisioned at all and the rollback never has more than one
// principal to consider. The case worth testing is the one review
// described: the first role succeeds, the second fails, and the
// question is whether the first gets destroyed on the way out.
// Count GRANT plans, not ACL calls. applyWindowsPrincipalACLs revokes
// this trustee's existing ACEs before applying the new plan, so there
// are two calls per role now. The contract under test is "the second
// ROLE fails"; counting raw calls would fail the first role's grant
// instead and prove something else entirely.
grants := 0
applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) {
if len(plan.Entries) > 0 && plan.Entries[0].Action == windowsACLRevoke {
return func() error { return nil }, nil
}
grants++
if grants < 2 {
return func() error { return nil }, nil
}
return nil, errors.New("ACL apply refused")
}
if _, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig()); err == nil {
t.Fatal("setup reported success despite an injected ACL failure")
}
assertRolesEqual(t, removed, testCase.wantRemoved)
})
}
}
// The same contract on the success path: the rollback handed back to the caller
// for a LATER setup step to invoke must be just as reluctant.
func TestDualRoleSetupReturnedRollbackSparesAdoptedPrincipals(t *testing.T) {
var removed []windowsSandboxRole
restoreDualRoleSeams(t, map[windowsSandboxRole]bool{
windowsSandboxRoleOffline: false,
windowsSandboxRoleOnline: true,
}, &removed)
rollback, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig())
if err != nil {
t.Fatalf("setup: %v", err)
}
if len(removed) != 0 {
t.Fatalf("setup removed principals before anything failed: %v", removed)
}
if err := rollback(); err != nil {
t.Fatalf("rollback: %v", err)
}
assertRolesEqual(t, removed, []windowsSandboxRole{windowsSandboxRoleOnline})
}
func restoreDualRoleSeams(t *testing.T, createdByRole map[windowsSandboxRole]bool, removed *[]windowsSandboxRole) {
t.Helper()
prevProvision := provisionWindowsSandboxPrincipalForSetupFn
prevApply := applyWindowsACLPlanFn
prevRemove := removeWindowsSandboxPrincipalForSetupFn
t.Cleanup(func() {
provisionWindowsSandboxPrincipalForSetupFn = prevProvision
applyWindowsACLPlanFn = prevApply
removeWindowsSandboxPrincipalForSetupFn = prevRemove
})
provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) {
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
return windowsSandboxIdentity{}, false, err
}
return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, createdByRole[role], nil
}
applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) {
return func() error { return nil }, nil
}
removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error {
*removed = append(*removed, role)
return nil
}
}
func windowsSandboxTestConfig() WindowsSandboxCommandConfig {
return WindowsSandboxCommandConfig{
SandboxHome: `C:\sandboxhome`,
CommandCWD: `C:\ws`,
WorkspaceRoots: []string{`C:\ws`},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{Root: `C:\ws`}},
},
},
}
}
func assertRolesEqual(t *testing.T, got []windowsSandboxRole, want []windowsSandboxRole) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("removed %v, want %v", got, want)
}
seen := map[windowsSandboxRole]bool{}
for _, role := range got {
seen[role] = true
}
for _, role := range want {
if !seen[role] {
t.Fatalf("removed %v, want %v", got, want)
}
}
}
@@ -1,66 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// The git control-plane carveouts are two different shapes: .git/hooks is a
// directory, .git/config is a FILE. Materialization has to respect that.
//
// On a fresh workspace neither exists yet, which is precisely the case
// Materialize was added for — so this is the common path, not a corner. Creating
// .git/config as a directory does not just mis-ACL it: it makes the workspace
// permanently unusable, because git refuses to initialise over a directory
// where its config file belongs.
func TestPrincipalACLPlanMaterializesGitConfigAsFile(t *testing.T) {
workspace := t.TempDir()
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
// Guests, deliberately: the deny-write ACE must land on the sandbox
// principal, not on whoever runs the test. With Everyone (S-1-1-0) the
// ACE denies the test process too and `git init` fails with "Permission
// denied" for a reason that has nothing to do with the shape bug.
PrincipalSID: "S-1-5-32-546",
WriteRoots: []WritableRoot{{
Root: workspace,
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
}},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
rollback, err := applyWindowsACLPlan(plan)
if err != nil {
t.Fatalf("applyWindowsACLPlan: %v", err)
}
t.Cleanup(func() {
if rollback != nil {
_ = rollback()
}
})
configPath := filepath.Join(workspace, ".git", "config")
if info, err := os.Stat(configPath); err == nil && info.IsDir() {
t.Errorf(".git/config was materialized as a directory; git requires a file")
}
hooksPath := filepath.Join(workspace, ".git", "hooks")
if info, err := os.Stat(hooksPath); err == nil && !info.IsDir() {
t.Errorf(".git/hooks was materialized as a file; git requires a directory")
}
// The failure users would actually hit.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH; shape assertions above still ran")
}
cmd := exec.Command("git", "init")
cmd.Dir = workspace
if out, err := cmd.CombinedOutput(); err != nil {
t.Errorf("git init failed on a workspace after sandbox setup: %v\n%s", err, out)
}
}
@@ -1,145 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// windowsPathDeniesDelete reports whether the object's real DACL carries a deny
// ACE covering DELETE for the given SID.
//
// The plan-shape tests assert what the planner emits. This reads what actually
// landed on disk, which is the gap that let the guard go missing: the plan was
// right the whole time and the apply silently dropped it.
func windowsPathDeniesDelete(t *testing.T, path, sid string) bool {
t.Helper()
handle, _, err := openWindowsACLTarget(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer func() { _ = windows.CloseHandle(handle) }()
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Fatalf("read DACL for %s: %v", path, err)
}
// SDDL rather than walking the ACE buffer by hand: this x/sys does not export
// an ACE enumerator, and unsafe pointer arithmetic in a test that exists to
// catch a security regression is its own hazard.
//
// An ACE renders as (type;flags;rights;guid;inherit_guid;sid), so the deny
// entries are the ones whose type field is D. Rights come back as SDDL
// abbreviations when they fit and as a hex mask when they do not, so both are
// accepted; SD is the abbreviation for DELETE.
for _, ace := range strings.Split(descriptor.String(), "(") {
fields := strings.Split(strings.TrimSuffix(strings.TrimSpace(ace), ")"), ";")
if len(fields) != 6 || fields[0] != "D" || !strings.EqualFold(fields[5], sid) {
continue
}
rights := fields[2]
if strings.Contains(rights, "SD") {
return true
}
if mask, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(rights), "0x"), 16, 32); err == nil {
if uint32(mask)&uint32(windows.DELETE) != 0 {
return true
}
}
}
return false
}
// THE GUARD MUST REACH DISK ON A WORKSPACE THAT HAD NO .git.
//
// This is the case the whole plan is built around and it was the one case where
// the guard was absent. .git deliberately carries no Materialize, because an
// empty .git breaks `git init`. Groups are applied in ascending path order, so
// the .git group ran while .git did not yet exist, was skipped as a
// non-materializing missing target, and nothing revisited it once the
// .git\config carveout created .git as its parent chain moments later.
//
// Four tests already covered the deny-delete mask and the plan emitting it.
// None of them applied the plan and looked at the object, which is exactly why
// a green suite said nothing.
func TestGitRenameGuardReachesDiskOnAWorkspaceWithoutGit(t *testing.T) {
const principal = testPrincipalSID // Unaliased, so it renders literally in SDDL.
workspace := t.TempDir()
gitDir := filepath.Join(workspace, ".git")
if _, err := os.Stat(gitDir); err == nil {
t.Fatal("the workspace already has .git, so this proves nothing")
}
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: principal,
WriteRoots: []WritableRoot{{
Root: workspace,
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
}},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
rollback, err := applyWindowsACLPlan(plan)
if err != nil {
t.Fatalf("applyWindowsACLPlan: %v", err)
}
t.Cleanup(func() {
if rollback != nil {
_ = rollback()
}
})
// The carveouts create .git on the way to .git\config.
if _, err := os.Stat(gitDir); err != nil {
t.Fatalf(".git was never created by the carveout materialization: %v", err)
}
if !windowsPathDeniesDelete(t, gitDir, principal) {
t.Fatal("no deny-delete ACE on .git after applying the plan: the principal can rename it aside and recreate it without the config and hooks carveouts")
}
}
// The same guard on a workspace that already had .git must keep working. This
// case was already correct, and it is kept so a fix aimed at the fresh
// workspace cannot quietly trade one for the other.
func TestGitRenameGuardStillReachesDiskWhenGitAlreadyExists(t *testing.T) {
const principal = testPrincipalSID
workspace := t.TempDir()
gitDir := filepath.Join(workspace, ".git")
if err := os.Mkdir(gitDir, 0o700); err != nil {
t.Fatalf("seed .git: %v", err)
}
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: principal,
WriteRoots: []WritableRoot{{
Root: workspace,
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
}},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
rollback, err := applyWindowsACLPlan(plan)
if err != nil {
t.Fatalf("applyWindowsACLPlan: %v", err)
}
t.Cleanup(func() {
if rollback != nil {
_ = rollback()
}
})
if !windowsPathDeniesDelete(t, gitDir, principal) {
t.Fatal("no deny-delete ACE on a .git that already existed")
}
}
@@ -1,98 +0,0 @@
package sandbox
import (
"path/filepath"
"testing"
)
// A sandbox principal must not be able to REPLACE .git.
//
// The write-denied carveouts are attached to .git/config and .git/hooks as
// objects. Rename .git aside, recreate it, and those objects are gone: the fresh
// config and hooks inherit the workspace allow with no deny of their own, which
// hands back credential.helper and core.hooksPath, and with them arbitrary code
// execution on the next git command.
//
// .git cannot join sandboxFullyProtectedMetadataNames to fix this, because that
// list emits DenyWrite, whose mask includes FILE_GENERIC_WRITE. Git has to write
// index, objects and refs. So the directory needs DELETE denied on itself while
// staying writable underneath.
func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) {
root := filepath.FromSlash("/ws/repo")
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: "S-1-5-21-1-2-3-1001",
WriteRoots: []WritableRoot{{
Root: root,
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
}},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
// The plan normalizes every write root before it names an ACE, so the
// expected path has to be normalized too or this compares two spellings of
// the same directory. Hardcoding a drive letter instead would pass on
// Windows and fail everywhere else, since the builder is portable code.
gitDir := filepath.Join(normalizeProfilePath(root), ".git")
var denyDelete *WindowsACLEntry
for index := range plan.Entries {
if plan.Entries[index].Action == WindowsACLDenyDelete && plan.Entries[index].Path == gitDir {
denyDelete = &plan.Entries[index]
break
}
}
if denyDelete == nil {
t.Fatalf("no deny-delete entry for %s, so the principal can rename .git and recreate it without the carveouts:\n%#v", gitDir, plan.Entries)
}
if denyDelete.Capability != "S-1-5-21-1-2-3-1001" {
t.Errorf("deny-delete names %q, want the principal SID", denyDelete.Capability)
}
}
// The guard must not become a write ban. Git writes constantly inside .git, so a
// deny that reached the children would break every commit rather than just the
// rename. It also must not be materialized into existence: .git is git's to
// create, and an empty .git directory made by setup breaks `git init`.
func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) {
root := filepath.FromSlash("/ws/repo")
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: "S-1-5-21-1-2-3-1001",
WriteRoots: []WritableRoot{{
Root: root,
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
}},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
gitDir := filepath.Join(normalizeProfilePath(root), ".git")
for _, entry := range plan.Entries {
if entry.Path != gitDir {
continue
}
if entry.Action == WindowsACLDenyWrite {
t.Errorf("deny-write on %s would stop git writing index/objects/refs", gitDir)
}
if entry.Action == WindowsACLDenyDelete && entry.Materialize {
t.Errorf("the rename guard materializes %s; git must create it, an empty .git breaks git init", gitDir)
}
}
// The existing carveouts must survive unchanged.
for _, want := range []string{filepath.Join(gitDir, "config"), filepath.Join(gitDir, "hooks")} {
found := false
for _, entry := range plan.Entries {
if entry.Action == WindowsACLDenyWrite && entry.Path == want {
found = true
break
}
}
if !found {
t.Errorf("the write-deny carveout for %s disappeared", want)
}
}
}
@@ -1,81 +0,0 @@
//go:build windows
package sandbox
import (
"testing"
"golang.org/x/sys/windows"
)
// The mask is the whole point of the action, so it is asserted bit by bit.
//
// Renaming a directory needs DELETE on the directory itself, so denying DELETE
// is what stops .git being replaced. Everything else in the mask is there to
// stop the principal removing the guard: WRITE_DAC would let it rewrite the
// DACL, WRITE_OWNER would let it take ownership and then rewrite the DACL.
//
// What must NOT be in it matters just as much. FILE_GENERIC_WRITE would stop git
// writing index, objects and refs. FILE_DELETE_CHILD would stop git deleting its
// own lock files and refs. Either one turns a rename guard into a broken repo.
func TestDenyDeleteMaskStopsRenameWithoutStoppingGit(t *testing.T) {
mode, mask, err := windowsACLAccess(WindowsACLDenyDelete)
if err != nil {
t.Fatalf("windowsACLAccess(deny-delete): %v", err)
}
if mode != windows.DENY_ACCESS {
t.Fatalf("access mode = %v, want DENY_ACCESS", mode)
}
for _, required := range []struct {
name string
bit windows.ACCESS_MASK
}{
{"DELETE", windows.DELETE},
{"WRITE_DAC", windows.WRITE_DAC},
{"WRITE_OWNER", windows.WRITE_OWNER},
} {
if mask&required.bit == 0 {
t.Errorf("mask %#x is missing %s, so the guard can be removed or bypassed", mask, required.name)
}
}
for _, forbidden := range []struct {
name string
bit windows.ACCESS_MASK
breaks string
}{
{"FILE_GENERIC_WRITE", windows.FILE_GENERIC_WRITE, "git writing index/objects/refs"},
{"FILE_DELETE_CHILD", windowsFileDeleteChild, "git deleting its own lock files and refs"},
} {
if mask&forbidden.bit != 0 {
t.Errorf("mask %#x includes %s, which breaks %s", mask, forbidden.name, forbidden.breaks)
}
}
}
// Inheritance is the other half. An inherited deny would reach every file inside
// .git and stop git deleting anything at all, so this ACE has to apply to the
// directory object alone while the other actions keep inheriting as before.
func TestDenyDeleteDoesNotInheritWhileOtherActionsStillDo(t *testing.T) {
entries := []WindowsACLEntry{
{Action: WindowsACLDenyDelete, Path: `C:\work\repo\.git`, Capability: "S-1-5-32-9999"},
{Action: WindowsACLDenyWrite, Path: `C:\work\repo\.zero`, Capability: "S-1-5-32-9999"},
{Action: WindowsACLAllowWrite, Path: `C:\work\repo`, Capability: "S-1-5-32-9999"},
}
access, err := windowsExplicitAccessEntries(entries, true)
if err != nil {
t.Fatalf("windowsExplicitAccessEntries: %v", err)
}
if len(access) != len(entries) {
t.Fatalf("got %d access entries, want %d", len(access), len(entries))
}
if access[0].Inheritance != windows.NO_INHERITANCE {
t.Errorf("deny-delete inheritance = %#x, want NO_INHERITANCE; an inherited deny would stop git deleting inside .git", access[0].Inheritance)
}
for index, entry := range entries[1:] {
if got := access[index+1].Inheritance; got != windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT {
t.Errorf("%s inheritance = %#x, want the directory default to be unchanged", entry.Action, got)
}
}
}
@@ -1,86 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"strings"
"testing"
)
// A NAME IS NOT PROOF OF PROVENANCE, for the USERS group specifically.
//
// ensureWindowsLocalGroup covers both managed groups, and the sibling test in
// windows_offline_group_ownership_windows_test.go pins the offline one. This
// pins ZeroSandboxUsers, which is the group anandh8x reported: it holds every
// sandbox principal, so adopting a same-named group created by an installer or
// by policy would hand the principal whatever that group already grants.
//
// Driven through the seams so it needs no Administrator and leaves no real local
// group on the machine running the suite.
func TestAdoptingAForeignUsersGroupIsRefused(t *testing.T) {
for _, status := range []uintptr{nerrGroupExists, errorAliasExists} {
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return status, nil }
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, nil }
err := ensureWindowsSandboxGroup()
if err == nil {
t.Fatalf("status %d adopted a group Zero did not create, so the principal inherits whatever it already grants", status)
}
if !strings.Contains(err.Error(), windowsSandboxGroupName) {
t.Errorf("the refusal must name the group in the way, got %q", err)
}
}
}
// Our own group is still adopted, or re-running setup would fail on the group it
// created a moment ago and provisioning would never converge.
func TestAdoptingOurOwnUsersGroupSucceeds(t *testing.T) {
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
windowsLocalGroupOwnedByZeroFn = func(name, comment string) (bool, error) {
if name != windowsSandboxGroupName || comment != windowsSandboxGroupComment {
t.Errorf("ownership probed for %q/%q, want the users group", name, comment)
}
return true, nil
}
if err := ensureWindowsSandboxGroup(); err != nil {
t.Errorf("refused a group carrying Zero's own comment: %v", err)
}
}
// A freshly created group is ours by construction, so no ownership probe runs.
func TestCreatingTheUsersGroupDoesNotProbeOwnership(t *testing.T) {
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
probed := false
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrSuccess, nil }
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { probed = true; return false, nil }
if err := ensureWindowsSandboxGroup(); err != nil {
t.Fatalf("a successful create was rejected: %v", err)
}
if probed {
t.Error("ownership was probed for a group we had just created ourselves")
}
}
// A failed ownership probe is not read as "ours" and not as "not ours" either.
func TestAnUnreadableUsersGroupIsNotGuessedEitherWay(t *testing.T) {
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
sentinel := errors.New("NetLocalGroupGetInfo: status 5")
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, sentinel }
if err := ensureWindowsSandboxGroup(); !errors.Is(err, sentinel) {
t.Fatalf("a failed ownership probe was swallowed, got %v", err)
}
}
-231
View File
@@ -1,231 +0,0 @@
package sandbox
// ACLs for a sandbox principal.
//
// The capability-SID model this sits beside starts from "the caller can already
// read everything" and narrows writes, because the sandboxed child runs as the
// caller. A principal inverts that: a separate local account has no access to
// the caller's profile at all, so the interesting direction is what to GRANT.
//
// That inversion is the point. Credential stores under the user's profile are
// unreachable because the principal is a different account, not because a deny
// rule enumerated them, which is what makes this able to close #662 and #675 on
// Windows where a deny-read ACE against the caller's own SID never could. Deny
// rules stay useful only for objects that are readable by everyone.
//
// Grants are explicit and narrow: the workspace and any extra write roots get
// read+write, declared read-only roots get read, and the protected metadata
// carve-outs the profile already defines stay denied so .git internals and
// .zero/.agents cannot be rewritten from inside the sandbox.
import (
"errors"
"fmt"
"path/filepath"
)
// WindowsACLAllowRead grants read and execute without write. It exists for the
// principal model, where a read root must be granted rather than assumed.
const WindowsACLAllowRead WindowsACLAction = "allow-read"
// windowsPrincipalACLInput is everything needed to describe a principal's
// access. It is deliberately a plain struct rather than the full command config
// so the plan can be built and tested without a live sandbox.
type windowsPrincipalACLInput struct {
// PrincipalSID is the string SID of the sandbox account every ACE names.
PrincipalSID string
// WriteRoots receive read+write+execute. The workspace lives here.
WriteRoots []WritableRoot
// ReadRoots receive read+execute only.
ReadRoots []string
// DenyRead covers objects a principal could otherwise reach because they are
// world-readable; per-user secrets need no entry.
DenyRead []string
// DenyWrite carries the policy's own deny-write paths. The capability plan
// has always emitted these; the principal plan denied write only on
// protected metadata and read-only subpaths inside write roots, so a policy
// deny sitting anywhere else was simply not enforced once the runner used a
// principal token, and a shell child could write where the restricted-token
// backend would have blocked it.
DenyWrite []string
}
// buildWindowsPrincipalACLPlan turns a principal's access into ACL entries.
//
// Ordering matters at apply time: deny entries are emitted before allows so a
// carve-out inside a granted root survives, which mirrors how Windows evaluates
// an explicit DACL (deny ACEs first).
func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPlan, error) {
if input.PrincipalSID == "" {
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires a principal SID")
}
if len(input.WriteRoots) == 0 && len(input.ReadRoots) == 0 {
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires at least one root")
}
entries := make([]WindowsACLEntry, 0, len(input.WriteRoots)*2+len(input.ReadRoots)+len(input.DenyRead))
// Deny first. A deny ACE inside a write root (protected metadata, git
// internals) has to win over the grant that follows it.
// Materialized, matching the capability plan. applyWindowsACLPlan skips a
// target that does not exist, so without this a deny-read path created after
// setup ran never got an ACE at all and the principal could read it. The
// deny has to be in place before the object is.
for _, path := range normalizeProfilePaths(input.DenyRead) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyRead,
Path: path,
Capability: input.PrincipalSID,
Materialize: true,
})
}
for _, path := range normalizeProfilePaths(input.DenyWrite) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: path,
Capability: input.PrincipalSID,
Materialize: true,
})
}
for _, root := range input.WriteRoots {
// Normalized the same way as read and deny paths: a write root may arrive
// with "~" or as a relative path, and an ACE has to name the same absolute,
// symlink-resolved object the deny entries do or the two disagree.
cleaned := normalizeProfilePath(root.Root)
if cleaned == "" {
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: unusable write root %q", root.Root)
}
// Materialized, like the metadata and policy denies below and above.
//
// These are the git control-plane carveouts (.git/config, .git/hooks). On a
// workspace where git has not run yet they do not exist at setup time, and
// applyWindowsACLPlan skips a target that is absent, so the ACEs were never
// written. Once git created those paths the principal still held inherited
// write access to the workspace and could install a hook or rewrite
// credential.helper. The capability plan gets away without this because its
// child runs as the caller; a separate principal account does not.
// Which carveouts are files rather than directories comes from the same
// spec list the profile built ReadOnlySubpaths from, so a new carveout
// cannot be added without its shape coming along.
for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: subpath,
Capability: input.PrincipalSID,
Materialize: true,
MaterializeFile: gitMetadataCarveoutIsFile(subpath),
})
}
for _, name := range root.ProtectedMetadataNames {
// These are documented as NAMES, and the join below is what makes that
// documentation load-bearing rather than descriptive: ".." or a
// separator would place this deny ACE, and the directory it
// materializes, outside the write root entirely. Today every caller
// passes a package constant, so this is unreachable; it is here so that
// stays true when a future caller sources these from config.
if err := validateWindowsACLComponent(name); err != nil {
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: protected metadata name: %w", err)
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),
Capability: input.PrincipalSID,
Materialize: true,
})
}
// .git gets DELETE denied on the directory itself, because the carveouts
// that protect it are attached to .git/config and .git/hooks as OBJECTS.
// Rename .git aside and recreate it and those objects are gone, so the
// fresh config and hooks inherit the workspace allow with no deny of their
// own, handing back credential.helper and core.hooksPath.
//
// Not DenyWrite (git writes index, objects and refs), not materialized
// (git creates .git, and an empty one breaks git init), and not inherited,
// so everything underneath stays writable.
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyDelete,
Path: windowsRenameProtectedObject(cleaned),
Capability: input.PrincipalSID,
})
}
// Then the grants the principal cannot work without.
for _, root := range input.WriteRoots {
cleaned := normalizeProfilePath(root.Root)
if cleaned == "" {
continue
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLAllowWrite,
Path: cleaned,
Capability: input.PrincipalSID,
})
}
for _, path := range normalizeProfilePaths(input.ReadRoots) {
// NEVER grant at a volume root.
//
// permissionProfileReadRoots seeds its list with profileRootPath(), which
// is the separator alone, because the workspace-write posture is
// read-all/write-jail. That is harmless for the capability backend, whose
// child runs as the CALLER and therefore reads what the caller could read
// anyway. It is not harmless here: a principal is a separate local
// account, so this loop turns that synthetic entry into a real,
// persistent, inheritable allow-read ACE for that account at the root of
// the current drive, reaching every directory that does not block
// inheritance.
//
// Dropping it does not take away the reads the principal needs to run
// commands. NetUserAdd with USER_PRIV_USER puts the account in the
// built-in Users group (see usrPrivUser in windows_identity_windows.go),
// and the machine's own ACLs already grant Users read on the system and
// program directories. What the grant added on top was read access to the
// places Users are deliberately kept out of, which is the opposite of what
// a sandbox is for.
//
// Note this inherits rather than asserts: nothing here checks that those
// default ACLs are actually in place, so a hardened image that strips
// Users read would need an explicit bounded read set instead.
if isWindowsVolumeRoot(path) {
continue
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLAllowRead,
Path: path,
Capability: input.PrincipalSID,
})
}
return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
}
// windowsPrincipalRevokePlan returns the entries whose ACEs should be removed
// when a principal is retired. Revocation is by TRUSTEE rather than by path:
// every ACE naming this principal is dropped, so cleanup does not depend on
// remembering which paths were granted, and a grant added by an older version
// is still removed.
//
// This is the removal path the capability-SID model never had, where a synthetic
// SID left ACEs behind on the user's tree with nothing to match them against.
func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACLPlan, error) {
if principalSID == "" {
return WindowsACLPlan{}, errors.New("windows principal revoke plan requires a principal SID")
}
cleaned := normalizeProfilePaths(paths)
entries := make([]WindowsACLEntry, 0, len(cleaned))
for _, path := range cleaned {
entries = append(entries, WindowsACLEntry{
Action: windowsACLRevoke,
Path: path,
Capability: principalSID,
})
}
return WindowsACLPlan{Entries: entries}, nil
}
// windowsACLRevoke removes every ACE naming the trustee on a path, whatever
// access it granted or denied.
const windowsACLRevoke WindowsACLAction = "revoke"
// windowsACLPlanPaths lives in windows_identity_runtime_windows.go, beside its
// only callers. It was here, in the portable file, which made it dead code on
// every non-Windows build and failed the static analysis gate.
@@ -1,275 +0,0 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
const testPrincipalSID = "S-1-5-21-1111111111-2222222222-3333333333-1005"
func testPrincipalInput() windowsPrincipalACLInput {
return windowsPrincipalACLInput{
PrincipalSID: testPrincipalSID,
WriteRoots: []WritableRoot{{
Root: filepath.FromSlash("/ws/project"),
ReadOnlySubpaths: []string{filepath.FromSlash("/ws/project/.git/config")},
ProtectedMetadataNames: []string{".zero", ".agents"},
}},
ReadRoots: []string{filepath.FromSlash("/usr/lib")},
DenyRead: []string{filepath.FromSlash("/shared/secrets")},
}
}
// Windows evaluates an explicit DACL deny-before-allow, so a carve-out inside a
// granted root only survives if its deny ACE is written first. If the grant on
// the workspace landed before the deny on .zero, the protected metadata would be
// writable from inside the sandbox.
func TestPrincipalACLPlanEmitsDeniesBeforeAllows(t *testing.T) {
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
if err != nil {
t.Fatalf("build: %v", err)
}
lastDeny, firstAllow := -1, -1
for index, entry := range plan.Entries {
switch entry.Action {
case WindowsACLDenyRead, WindowsACLDenyWrite:
lastDeny = index
case WindowsACLAllowWrite, WindowsACLAllowRead:
if firstAllow == -1 {
firstAllow = index
}
}
}
if firstAllow == -1 || lastDeny == -1 {
t.Fatalf("plan is missing a deny or an allow: %+v", plan.Entries)
}
if lastDeny > firstAllow {
t.Fatalf("deny at %d comes after allow at %d; carve-outs would be overridden", lastDeny, firstAllow)
}
}
// Every ACE must name the sandbox principal. An entry with any other trustee
// would change access for a real user.
func TestPrincipalACLPlanNamesOnlyThePrincipal(t *testing.T) {
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
if err != nil {
t.Fatalf("build: %v", err)
}
for _, entry := range plan.Entries {
if entry.Capability != testPrincipalSID {
t.Fatalf("entry %+v names %q, want the principal SID", entry, entry.Capability)
}
}
}
// A principal is a separate account with no inherent access, so a write root
// must be granted read+write and a read root granted read. Without the grant the
// sandbox cannot open its own workspace.
func TestPrincipalACLPlanGrantsRoots(t *testing.T) {
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
if err != nil {
t.Fatalf("build: %v", err)
}
var grantedWrite, grantedRead bool
for _, entry := range plan.Entries {
if entry.Action == WindowsACLAllowWrite && entry.Path == normalizeProfilePath(filepath.FromSlash("/ws/project")) {
grantedWrite = true
}
if entry.Action == WindowsACLAllowRead && entry.Path == normalizeProfilePath(filepath.FromSlash("/usr/lib")) {
grantedRead = true
}
}
if !grantedWrite {
t.Fatal("write root was not granted; the sandbox could not write its workspace")
}
if !grantedRead {
t.Fatal("read root was not granted; the sandbox could not read it")
}
}
// Protected metadata is denied write and marked Materialize so the ACE is
// created even when the directory does not exist yet, closing the window where
// a sandboxed command creates .zero before the deny lands.
func TestPrincipalACLPlanProtectsMetadata(t *testing.T) {
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
if err != nil {
t.Fatalf("build: %v", err)
}
found := map[string]WindowsACLEntry{}
for _, entry := range plan.Entries {
found[entry.Path] = entry
}
for _, name := range []string{".zero", ".agents"} {
path := filepath.Join(normalizeProfilePath(filepath.FromSlash("/ws/project")), name)
entry, ok := found[path]
if !ok {
t.Fatalf("no entry protecting %s", path)
}
if entry.Action != WindowsACLDenyWrite {
t.Fatalf("%s has action %q, want deny-write", path, entry.Action)
}
if !entry.Materialize {
t.Fatalf("%s must be materialized so the deny exists before the directory does", path)
}
}
}
// A missing principal SID must be a hard error: an empty trustee would either
// fail at apply time or, worse, be interpreted as some other account.
func TestPrincipalACLPlanRequiresSID(t *testing.T) {
input := testPrincipalInput()
input.PrincipalSID = ""
if _, err := buildWindowsPrincipalACLPlan(input); err == nil {
t.Fatal("an empty principal SID must be rejected")
}
}
// A plan with no roots at all is a caller mistake rather than a valid empty
// grant, since the resulting sandbox could not run anything.
func TestPrincipalACLPlanRequiresRoots(t *testing.T) {
input := windowsPrincipalACLInput{PrincipalSID: testPrincipalSID}
if _, err := buildWindowsPrincipalACLPlan(input); err == nil {
t.Fatal("a plan with no roots must be rejected")
}
}
// Revocation is keyed to the trustee, so retiring a principal removes every ACE
// naming it without having to remember what was granted. This is the cleanup
// path the capability-SID model lacks.
func TestPrincipalRevokePlanTargetsTrustee(t *testing.T) {
paths := []string{filepath.FromSlash("/ws/project"), filepath.FromSlash("/usr/lib")}
plan, err := windowsPrincipalRevokePlan(testPrincipalSID, paths)
if err != nil {
t.Fatalf("revoke plan: %v", err)
}
if len(plan.Entries) != len(paths) {
t.Fatalf("got %d entries, want %d", len(plan.Entries), len(paths))
}
for _, entry := range plan.Entries {
if entry.Action != windowsACLRevoke {
t.Fatalf("entry %+v is not a revoke", entry)
}
if entry.Capability != testPrincipalSID {
t.Fatalf("revoke names %q, want the principal", entry.Capability)
}
}
}
func TestPrincipalRevokePlanRequiresSID(t *testing.T) {
if _, err := windowsPrincipalRevokePlan("", []string{"/ws"}); err == nil {
t.Fatal("revoking without a principal SID must be rejected")
}
}
// The action strings end up in a serialized plan consumed by the elevated
// helper, so they must stay stable and distinct from the existing actions.
func TestPrincipalACLActionsAreDistinct(t *testing.T) {
actions := []WindowsACLAction{
WindowsACLAllowWrite, WindowsACLAllowRead,
WindowsACLDenyRead, WindowsACLDenyWrite, windowsACLRevoke,
}
seen := map[WindowsACLAction]bool{}
for _, action := range actions {
if strings.TrimSpace(string(action)) == "" {
t.Fatal("an action string is empty")
}
if seen[action] {
t.Fatalf("duplicate action %q", action)
}
seen[action] = true
}
}
// ProtectedMetadataNames is joined onto the write root to place a deny ACE and to
// materialize the directory it names. A value that is not a single component
// therefore puts both OUTSIDE the workspace: ".." walks up out of it, and a
// separator reaches through whatever sits in between. Every caller passes a
// package constant today, so this is the guard that keeps it true if one ever
// sources these from config.
func TestPrincipalACLPlanRefusesProtectedNamesThatEscapeTheWriteRoot(t *testing.T) {
root := filepath.FromSlash("/ws/project")
for _, name := range []string{"..", ".", "", `..\..\Windows\System32`, "nested/child", `nested\child`, "C:", "stream:name"} {
t.Run(name, func(t *testing.T) {
input := testPrincipalInput()
input.WriteRoots = []WritableRoot{{
Root: root,
ProtectedMetadataNames: []string{name},
}}
plan, err := buildWindowsPrincipalACLPlan(input)
if err == nil {
t.Fatalf("accepted protected metadata name %q, which would place a deny ACE outside %s:\n%#v", name, root, plan.Entries)
}
if len(plan.Entries) != 0 {
t.Errorf("returned %d entries alongside the error, so a caller ignoring err would still apply them", len(plan.Entries))
}
})
}
}
// A PRINCIPAL MUST NEVER BE GRANTED READ AT A VOLUME ROOT.
//
// permissionProfileReadRoots seeds its list with the bare separator, because
// the workspace-write posture is read-all with a write jail. That costs nothing
// for the capability backend, whose child runs as the caller and could read
// those paths anyway. For a principal it is a real, persistent, inheritable
// allow-read ACE for a separate local account at the root of the drive.
//
// Built from the production profile rather than a synthetic fixture, because
// the whole point is that the shipped configuration produced it.
func TestPrincipalACLPlanNeverGrantsReadAtAVolumeRoot(t *testing.T) {
workspace := filepath.FromSlash("/ws/project")
profile := DefaultPermissionProfile(workspace)
// If the profile ever stops carrying a volume root, this test proves nothing
// and should be retired rather than left passing vacuously.
seeded := false
for _, root := range profile.FileSystem.ReadRoots {
if isWindowsVolumeRoot(normalizeProfilePath(root)) {
seeded = true
break
}
}
if !seeded {
t.Skipf("the production profile no longer contains a volume read root: %v", profile.FileSystem.ReadRoots)
}
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: testPrincipalSID,
ReadRoots: profile.FileSystem.ReadRoots,
WriteRoots: profile.FileSystem.WriteRoots,
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
for _, entry := range plan.Entries {
if entry.Action == WindowsACLAllowRead && isWindowsVolumeRoot(entry.Path) {
t.Errorf("plan grants the principal read at the volume root %q, which inherits into every directory on the drive", entry.Path)
}
}
// And the workspace itself must still be reachable, or this traded a real
// grant for a broken sandbox.
wantWorkspace := normalizeProfilePath(workspace)
reachable := false
for _, entry := range plan.Entries {
if entry.Path == wantWorkspace && (entry.Action == WindowsACLAllowRead || entry.Action == WindowsACLAllowWrite) {
reachable = true
break
}
}
if !reachable {
t.Errorf("no grant for the workspace root %q, so the principal could not read its own workspace", wantWorkspace)
}
}
// The ordinary names must keep working, or the guard above is just a break.
func TestPrincipalACLPlanStillAcceptsTheRealProtectedNames(t *testing.T) {
input := testPrincipalInput()
input.WriteRoots = []WritableRoot{{
Root: filepath.FromSlash("/ws/project"),
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
}}
if _, err := buildWindowsPrincipalACLPlan(input); err != nil {
t.Fatalf("the shipped protected names were rejected: %v", err)
}
}
@@ -1,76 +0,0 @@
//go:build windows
package sandbox
// DPAPI wrapping for the stored sandbox principal password.
//
// The file ACL is the primary control and remains the thing that keeps the
// sandbox principal itself from reading its own credential. This adds the layer
// the ACL cannot: an ACL is only meaningful while the filesystem is being asked
// to enforce it, so a backup, a mounted disk image, or a copy taken by anyone
// who can bypass the DACL yields the password in the clear. CryptProtectData
// binds the ciphertext to the invoking user's logon secret, so an offline copy
// is inert without that user's credentials.
//
// The principal name is passed as optional entropy, which makes a blob usable
// only for the account it was minted for; moving one secret file over another
// then fails to decrypt instead of silently authenticating the wrong principal.
import (
"errors"
"fmt"
"unsafe"
"golang.org/x/sys/windows"
)
// protectWindowsSecret encrypts a password to the current user.
//
// CRYPTPROTECT_UI_FORBIDDEN matters here: this runs inside a CLI and, on the
// setup path, potentially without an interactive desktop, so DPAPI must fail
// rather than try to prompt.
func protectWindowsSecret(plaintext string, entropy string) ([]byte, error) {
if plaintext == "" {
return nil, errors.New("windows sandbox secret: refusing to protect an empty password")
}
in := windows.DataBlob{
Size: uint32(len(plaintext)),
Data: &[]byte(plaintext)[0],
}
entropyBytes := []byte(entropy)
var entropyBlob *windows.DataBlob
if len(entropyBytes) > 0 {
entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]}
}
var out windows.DataBlob
if err := windows.CryptProtectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
return nil, fmt.Errorf("protect sandbox secret: %w", err)
}
// DPAPI allocates the output with LocalAlloc; copy it out and hand it back.
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil
}
// unprotectWindowsSecret reverses protectWindowsSecret. It fails for any user
// other than the one that wrote the blob, and for a blob minted with a different
// principal name as entropy.
func unprotectWindowsSecret(ciphertext []byte, entropy string) (string, error) {
if len(ciphertext) == 0 {
return "", errWindowsSandboxIdentityUnavailable
}
in := windows.DataBlob{
Size: uint32(len(ciphertext)),
Data: &ciphertext[0],
}
entropyBytes := []byte(entropy)
var entropyBlob *windows.DataBlob
if len(entropyBytes) > 0 {
entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]}
}
var out windows.DataBlob
if err := windows.CryptUnprotectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
return "", fmt.Errorf("unprotect sandbox secret: %w", err)
}
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
return string(unsafe.Slice(out.Data, out.Size)), nil
}
@@ -1,270 +0,0 @@
//go:build windows
package sandbox
// Minting a token for a sandbox principal.
//
// A provisioned account is inert until something can log on as it. Windows
// gates that behind account rights held in the local security policy, so setup
// grants the principal exactly one: the right to be logged on as a batch job,
// which is what a non-interactive service-style logon needs. It is deliberately
// NOT granted interactive, network or remote-interactive logon, and those three
// are explicitly DENIED, so the account cannot be used to sign in at the
// console, over SMB, or through RDP even if its password leaked. The password
// exists only so LogonUser can mint a token; nobody is meant to type it.
//
// Rights are granted at setup (elevated) because LsaAddAccountRights requires
// administrator privileges. The per-command path only calls LogonUser, which
// needs no special privilege once the batch right is in place.
import (
"errors"
"fmt"
"runtime"
"unsafe"
"golang.org/x/sys/windows"
)
const (
// Logon type/provider for a non-interactive token. Batch is the closest
// match for "run this command as a service-like principal": it produces a
// full token without a desktop or network-credential footprint.
logon32LogonBatch = 4
logon32ProviderDefault = 0
// LSA policy access rights needed to add account rights.
policyCreateAccount = 0x00000010
policyLookupNames = 0x00000800
// Account rights. The sandbox principal gets the batch right and is denied
// every interactive path.
seBatchLogonRight = "SeBatchLogonRight"
seDenyInteractiveLogonRight = "SeDenyInteractiveLogonRight"
seDenyNetworkLogonRight = "SeDenyNetworkLogonRight"
seDenyRemoteInteractiveRight = "SeDenyRemoteInteractiveLogonRight"
seDenyServiceLogonRightName = "SeDenyServiceLogonRight"
windowsIdentityLogonRightsNote = "granted by `zero sandbox setup`"
)
var (
procLogonUserW = windows.NewLazySystemDLL("advapi32.dll").NewProc("LogonUserW")
procLsaOpenPolicy = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaOpenPolicy")
procLsaClose = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaClose")
procLsaAddAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaAddAccountRights")
// Retiring a principal has to drop its rights as well as its account, or the
// LSA policy database keeps an entry keyed to a SID that no longer resolves.
procLsaRemoveAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaRemoveAccountRights")
procLsaNtStatusToWinErr = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaNtStatusToWinError")
)
// lsaUnicodeString mirrors LSA_UNICODE_STRING. Length and MaximumLength are
// BYTE counts, not rune counts, which is the usual source of bugs here.
type lsaUnicodeString struct {
Length uint16
MaximumLength uint16
Buffer *uint16
}
// lsaObjectAttributes mirrors LSA_OBJECT_ATTRIBUTES. Every field except Length
// is unused for LsaOpenPolicy, but the struct must still be the right size.
type lsaObjectAttributes struct {
Length uint32
RootDirectory windows.Handle
ObjectName *lsaUnicodeString
Attributes uint32
SecurityDescriptor unsafe.Pointer
SecurityQualityOfService unsafe.Pointer
}
// newLSAString builds an LSA_UNICODE_STRING over a UTF-16 buffer the caller
// keeps alive. The returned value borrows that buffer, so the buffer must
// outlive every use of the string.
func newLSAString(buffer []uint16) lsaUnicodeString {
if len(buffer) == 0 {
return lsaUnicodeString{}
}
// The buffer is NUL-terminated; the LSA length counts bytes WITHOUT the
// terminator, while MaximumLength counts bytes WITH it.
runes := len(buffer) - 1
return lsaUnicodeString{
Length: uint16(runes * 2),
MaximumLength: uint16(len(buffer) * 2),
Buffer: &buffer[0],
}
}
// lsaStatusError converts an NTSTATUS from an Lsa* call into a Go error, going
// through LsaNtStatusToWinError so the message is the familiar Win32 one rather
// than a raw NTSTATUS.
func lsaStatusError(call string, status uintptr) error {
if status == 0 {
return nil
}
winErr, _, _ := procLsaNtStatusToWinErr.Call(status)
return fmt.Errorf("%s: %w", call, windows.Errno(winErr))
}
// grantWindowsSandboxLogonRights gives the principal the batch logon right and
// denies every interactive logon path. Idempotent: LsaAddAccountRights silently
// succeeds when the account already holds a right, so setup can re-run.
//
// Requires an elevated caller.
func grantWindowsSandboxLogonRights(sid *windows.SID) error {
if sid == nil {
return fmt.Errorf("grant sandbox logon rights: nil SID")
}
var attributes lsaObjectAttributes
attributes.Length = uint32(unsafe.Sizeof(attributes))
var policy windows.Handle
status, _, _ := procLsaOpenPolicy.Call(
0, // local system
uintptr(unsafe.Pointer(&attributes)),
uintptr(policyCreateAccount|policyLookupNames),
uintptr(unsafe.Pointer(&policy)),
)
// LsaOpenPolicy borrows the attributes struct by address, so it has to stay
// reachable until the call has returned.
runtime.KeepAlive(attributes)
if err := lsaStatusError("LsaOpenPolicy", status); err != nil {
return err
}
defer procLsaClose.Call(uintptr(policy))
rights := []string{
seBatchLogonRight,
seDenyInteractiveLogonRight,
seDenyNetworkLogonRight,
seDenyRemoteInteractiveRight,
seDenyServiceLogonRightName,
}
// Each right is added on its own call so one unsupported name on an odd SKU
// cannot silently drop the others.
for _, right := range rights {
buffer, err := windows.UTF16FromString(right)
if err != nil {
return err
}
entry := newLSAString(buffer)
status, _, _ := procLsaAddAccountRights.Call(
uintptr(policy),
uintptr(unsafe.Pointer(sid)),
uintptr(unsafe.Pointer(&entry)),
1,
)
// Both the descriptor and the buffer it points at are borrowed by the
// call. Kept alive before the error check, not after, so the failure path
// does not return with them already collectable.
runtime.KeepAlive(entry)
runtimeKeepAliveUint16(buffer)
if err := lsaStatusError("LsaAddAccountRights("+right+")", status); err != nil {
return err
}
}
return nil
}
// revokeWindowsSandboxLogonRights drops every account right held by a principal
// and removes its entry from the LSA policy database.
//
// This is the logon-rights counterpart to revoking ACEs by trustee, and it has
// the same reason to exist: deleting the account on its own leaves the rights
// behind, keyed to a SID that no longer resolves, which is the orphaned residue
// this model is supposed to avoid. It must therefore run BEFORE the account is
// deleted, while the SID is still resolvable.
//
// Removing all rights rather than naming them is deliberate. The principal is
// being retired, so anything keyed to it should go, including rights a previous
// version of setup granted and this one no longer knows about.
//
// Requires an elevated caller. A principal that holds no rights is not an error:
// LsaRemoveAccountRights reports ERROR_FILE_NOT_FOUND for an account with no LSA
// entry, which is the state teardown is trying to reach anyway.
func revokeWindowsSandboxLogonRights(sid *windows.SID) error {
if sid == nil {
return fmt.Errorf("revoke sandbox logon rights: nil SID")
}
var attributes lsaObjectAttributes
attributes.Length = uint32(unsafe.Sizeof(attributes))
var policy windows.Handle
status, _, _ := procLsaOpenPolicy.Call(
0, // local system
uintptr(unsafe.Pointer(&attributes)),
uintptr(policyCreateAccount|policyLookupNames),
uintptr(unsafe.Pointer(&policy)),
)
runtime.KeepAlive(attributes)
if err := lsaStatusError("LsaOpenPolicy", status); err != nil {
return err
}
defer procLsaClose.Call(uintptr(policy))
status, _, _ = procLsaRemoveAccountRights.Call(
uintptr(policy),
uintptr(unsafe.Pointer(sid)),
1, // AllRights: drop everything and delete the LSA account object
0, // UserRights ignored when AllRights is set
0, // CountOfRights likewise
)
runtime.KeepAlive(sid)
if err := lsaStatusError("LsaRemoveAccountRights", status); err != nil {
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
return nil
}
return err
}
return nil
}
// logonWindowsSandboxPrincipal mints a primary token for the sandbox account.
// The caller owns the returned token and must Close it.
//
// This needs no elevation: the batch logon right granted at setup is what makes
// it work, which is why the per-command path can run unelevated once setup has
// been done once.
func logonWindowsSandboxPrincipal(username string, password string) (windows.Token, error) {
user, err := windows.UTF16PtrFromString(username)
if err != nil {
return 0, err
}
// "." is the local machine, so the lookup never leaves this host even if the
// machine is domain-joined and a same-named domain account exists.
domain, err := windows.UTF16PtrFromString(".")
if err != nil {
return 0, err
}
secret, err := windows.UTF16PtrFromString(password)
if err != nil {
return 0, err
}
var token windows.Token
result, _, callErr := procLogonUserW.Call(
uintptr(unsafe.Pointer(user)),
uintptr(unsafe.Pointer(domain)),
uintptr(unsafe.Pointer(secret)),
logon32LogonBatch,
logon32ProviderDefault,
uintptr(unsafe.Pointer(&token)),
)
// The three strings are borrowed for the duration of the call.
runtime.KeepAlive(user)
runtime.KeepAlive(domain)
runtime.KeepAlive(secret)
if result == 0 {
if callErr != nil && callErr != windows.ERROR_SUCCESS {
return 0, fmt.Errorf("LogonUser(%s): %w", username, callErr)
}
return 0, fmt.Errorf("LogonUser(%s) failed", username)
}
return token, nil
}
// runtimeKeepAliveUint16 keeps a UTF-16 buffer reachable across a syscall that
// borrows it. Declared rather than inlined so the intent is explicit at each
// call site; the compiler must not free the slice while LSA holds the pointer.
func runtimeKeepAliveUint16(buffer []uint16) {
if len(buffer) == 0 {
return
}
_ = buffer[0]
}
@@ -1,41 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"testing"
"golang.org/x/sys/windows"
)
// revokeWindowsSandboxLogonRights treats "this account holds no rights" as
// success, because that is the state teardown is trying to reach anyway. That
// relies on the NTSTATUS for it surviving the trip through LsaNtStatusToWinError
// as an error errors.Is can still match, which is exactly the kind of Windows
// errno assumption that quietly turns out to be false. Assert it rather than
// trust it.
//
// Needs no privilege: LsaNtStatusToWinError is a pure status translation, so
// this runs everywhere rather than joining the gated set.
func TestLsaStatusErrorMapsObjectNameNotFound(t *testing.T) {
// STATUS_OBJECT_NAME_NOT_FOUND, what LsaRemoveAccountRights reports for an
// account that has no LSA entry.
const statusObjectNameNotFound = 0xC0000034
err := lsaStatusError("LsaRemoveAccountRights", statusObjectNameNotFound)
if err == nil {
t.Fatal("a nonzero NTSTATUS produced no error")
}
if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
t.Fatalf("error = %v, want one errors.Is matches against ERROR_FILE_NOT_FOUND; "+
"without that, revoking a principal that simply holds no rights fails teardown", err)
}
// The tolerance must be specific. If any failure matched it, revoke would
// swallow a real one and teardown would report success having done nothing.
const statusAccessDenied = 0xC0000022
if other := lsaStatusError("LsaRemoveAccountRights", statusAccessDenied); errors.Is(other, windows.ERROR_FILE_NOT_FOUND) {
t.Fatalf("access denied matched the not-found tolerance: %v", other)
}
}
@@ -1,409 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// Policy deny-write has to reach the principal plan.
//
// The capability plan has always emitted these. The principal plan denied write
// only on protected metadata and read-only subpaths inside write roots, so once
// the runner used a principal token a policy deny sitting anywhere else was not
// enforced at the OS layer at all, and a shell child could write where the
// restricted-token backend would have stopped it.
func TestPrincipalACLPlanCarriesPolicyDenyWrite(t *testing.T) {
root := filepath.Join(t.TempDir(), "workspace")
denied := filepath.Join(root, "protected", "keep-out")
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: "S-1-5-21-0-0-0-1001",
WriteRoots: []WritableRoot{{Root: root}},
DenyWrite: []string{denied},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, denied)
if !ok {
t.Fatalf("no deny-write ACE for the policy path; plan = %+v", plan.Entries)
}
// Materialized for the same reason the capability plan does it: the applier
// skips targets that do not exist, so a deny on a path created after setup
// would never be written.
if !entry.Materialize {
t.Error("policy deny-write ACE is not materialized, so it is skipped when the path does not exist yet")
}
}
// Deny-read has to be materialized too, which it was not.
func TestPrincipalACLPlanMaterializesDenyRead(t *testing.T) {
root := filepath.Join(t.TempDir(), "workspace")
secret := filepath.Join(t.TempDir(), "elsewhere", "creds")
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: "S-1-5-21-0-0-0-1001",
WriteRoots: []WritableRoot{{Root: root}},
DenyRead: []string{secret},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyRead, secret)
if !ok {
t.Fatalf("no deny-read ACE emitted; plan = %+v", plan.Entries)
}
if !entry.Materialize {
t.Fatal("deny-read ACE is not materialized, so a path created after setup never gets one")
}
}
// Deny entries must still precede the grants they carve out of, which is what
// makes them win under Windows DACL evaluation. Adding deny-write to the plan is
// only safe if it did not disturb that ordering.
func TestPrincipalACLPlanKeepsDeniesBeforeGrants(t *testing.T) {
root := filepath.Join(t.TempDir(), "workspace")
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: "S-1-5-21-0-0-0-1001",
WriteRoots: []WritableRoot{{Root: root}},
DenyWrite: []string{filepath.Join(root, "nope")},
DenyRead: []string{filepath.Join(root, "secret")},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
firstGrant := -1
for i, entry := range plan.Entries {
switch entry.Action {
case WindowsACLAllowWrite, WindowsACLAllowRead:
if firstGrant == -1 {
firstGrant = i
}
case WindowsACLDenyRead, WindowsACLDenyWrite:
if firstGrant != -1 {
t.Fatalf("deny entry at %d follows a grant at %d; the grant would win", i, firstGrant)
}
}
}
}
func findPrincipalACLEntry(plan WindowsACLPlan, action WindowsACLAction, path string) (WindowsACLEntry, bool) {
want := normalizeProfilePath(path)
for _, entry := range plan.Entries {
if entry.Action == action && entry.Path == want {
return entry, true
}
}
return WindowsACLEntry{}, false
}
// An adopted account must not have its password rotated during provisioning.
//
// Rotating there left every later step running against an account whose password
// had already been replaced with nothing on disk holding it. Any failure in
// between stranded a working principal: the rollback correctly declined to
// delete an account it had not created, so what remained was a live account
// authenticated by a password no longer stored anywhere, and the command path
// read the absent secret as "not provisioned" and quietly fell back to the
// weaker backend.
func TestProvisionWindowsSandboxIdentityDefersPasswordRotation(t *testing.T) {
stubWindowsProvisioning(t, true, nil, nil, nil)
rotated := false
previous := resetWindowsSandboxUserPasswordFn
t.Cleanup(func() { resetWindowsSandboxUserPasswordFn = previous })
resetWindowsSandboxUserPasswordFn = func(string, string) error {
rotated = true
return nil
}
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
t.Fatalf("provisioning an adopted account: %v", err)
}
if rotated {
t.Fatal("provisioning rotated the password; the window this closes lasts until the secret is committed")
}
}
// The ownership comment carries the full workspace key, so two workspaces whose
// digests collide in the 11 characters the account name can hold are refused
// rather than silently sharing one account, one secret and one ACL identity.
func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) {
first := windowsSandboxUserCommentFor("aaaaaaaaaaaabbbbbbbb")
second := windowsSandboxUserCommentFor("aaaaaaaaaaaacccccccc")
if first == second {
t.Fatal("two workspaces produced the same ownership comment, so a name collision would be adopted")
}
if !strings.HasPrefix(first, windowsSandboxUserComment) {
t.Fatalf("comment %q lost the marker prefix that identifies it as ours", first)
}
// The names DO collide, which is the whole reason the comment has to carry
// the key. If this stops being true the test is no longer covering anything.
if windowsSandboxUserName("aaaaaaaaaaaabbbbbbbb", windowsSandboxRoleOffline) != windowsSandboxUserName("aaaaaaaaaaaacccccccc", windowsSandboxRoleOffline) {
t.Skip("account names no longer collide for these keys; revisit what this test is for")
}
}
// Rollback must not strip an adopted principal's logon rights.
//
// revokeWindowsSandboxLogonRights passes AllRights, which drops every right the
// account holds and deletes its LSA object. On an account this run created that
// is a rollback; on one it adopted it destroys the SeBatchLogonRight and
// deny-logon rights an earlier setup established, which is the working
// principal this path exists to preserve.
func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) {
for name, testCase := range map[string]struct {
existed bool
wantRevoked bool
}{
"adopted principal": {existed: true, wantRevoked: false},
"created principal": {existed: false, wantRevoked: true},
} {
t.Run(name, func(t *testing.T) {
stubWindowsProvisioning(t, testCase.existed, nil, nil, nil)
revoked := false
prevGrant, prevRevoke := grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn
t.Cleanup(func() {
grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn = prevGrant, prevRevoke
})
// Fail the grant so the undo path runs with rights already attempted,
// which is the state that used to revoke unconditionally.
grantWindowsSandboxLogonRightsFn = func(*windows.SID) error {
return errors.New("LSA grant refused by policy")
}
revokeWindowsSandboxLogonRightsFn = func(*windows.SID) error {
revoked = true
return nil
}
config := WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
WorkspaceRoots: []string{`C:\ws`},
}
if _, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline); err == nil {
t.Fatal("provisioning reported success despite an injected grant failure")
}
if revoked != testCase.wantRevoked {
if testCase.wantRevoked {
t.Fatal("rights were not revoked for an account this run created, leaving LSA entries keyed to a SID about to be deleted")
}
t.Fatal("rights were revoked for an adopted account; AllRights drops its pre-existing rights and deletes the LSA object")
}
})
}
}
// A secret the current user cannot read is unavailability, not breakage.
//
// The secret's DACL names whoever ran setup. An operator who elevated with a
// separate administrative account, via runas or an over-the-shoulder UAC
// prompt, leaves a secret their ordinary account cannot open. Treating that as a
// hard error made every sandboxed command fail on a machine that was merely set
// up by a different admin; it belongs in the same fail-soft path as a missing
// secret, so the warning fires and the restricted token takes over.
func TestReadWindowsSandboxSecretTreatsPermissionDeniedAsUnavailable(t *testing.T) {
previous := readWindowsSandboxSecretFile
t.Cleanup(func() { readWindowsSandboxSecretFile = previous })
readWindowsSandboxSecretFile = func(string) ([]byte, error) {
return nil, &os.PathError{Op: "open", Path: "secret", Err: windows.ERROR_ACCESS_DENIED}
}
if _, err := readWindowsSandboxSecret(`C:\anything.secret`); err != errWindowsSandboxIdentityUnavailable {
t.Fatalf("permission-denied read returned %v, want errWindowsSandboxIdentityUnavailable so the command falls back", err)
}
}
// A workspace must not bind to another workspace's principal.
//
// The account name carries only 11 characters of the workspace digest, so two
// workspaces can derive the same name. Provisioning refuses that case by
// checking the full key in the account comment, but the command path resolved
// the name straight to a SID. The workspace that lost the race would have failed
// setup and then quietly run as the other one's principal, using its secret and
// its ACL identity.
func TestLookupWindowsSandboxIdentityRejectsForeignWorkspace(t *testing.T) {
previous := windowsSandboxUserIsManagedFn
t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous })
prevSID := resolveWindowsSandboxSIDFn
t.Cleanup(func() { resolveWindowsSandboxSIDFn = prevSID })
// The account resolves; whether it BELONGS to this workspace is the question.
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) {
return windows.CreateWellKnownSid(windows.WinLocalSystemSid)
}
var askedKey string
windowsSandboxUserIsManagedFn = func(_ string, workspaceKey string) (bool, error) {
askedKey = workspaceKey
return false, nil
}
_, err := lookupWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
if err == nil {
t.Fatal("lookup accepted an account belonging to another workspace")
}
if errors.Is(err, errWindowsSandboxIdentityUnavailable) {
t.Fatal("a foreign account must not read as unprovisioned; that would silently fall back instead of reporting the conflict")
}
if askedKey != "" && askedKey != "workspacekey" {
t.Fatalf("ownership was checked against %q, want the caller's workspace key", askedKey)
}
}
// An account that does not exist must stay the unavailable sentinel rather than
// becoming a collision error, since that is the ordinary not-set-up state.
func TestLookupWindowsSandboxIdentityAbsentAccountIsUnavailable(t *testing.T) {
previous := windowsSandboxUserIsManagedFn
t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous })
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) {
t.Fatal("ownership must not be consulted for an account that does not resolve")
return false, nil
}
if _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey", windowsSandboxRoleOffline); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
t.Fatalf("absent account returned %v, want errWindowsSandboxIdentityUnavailable", err)
}
}
// The git control-plane carveouts must be materialized.
//
// gitMetadataWriteCarveouts supplies .git/config and .git/hooks as
// ReadOnlySubpaths of the workspace. On a workspace where git has not run yet
// they do not exist when setup applies the plan, and applyWindowsACLPlan skips
// an absent target, so the deny ACEs were never written. Once git created those
// paths the principal still held inherited write access and could install a
// hook or rewrite credential.helper.
func TestPrincipalACLPlanMaterializesReadOnlySubpaths(t *testing.T) {
root := filepath.Join(t.TempDir(), "workspace")
carveout := filepath.Join(root, ".git", "config")
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
PrincipalSID: "S-1-5-21-0-0-0-1001",
WriteRoots: []WritableRoot{{Root: root, ReadOnlySubpaths: []string{carveout}}},
})
if err != nil {
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
}
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, carveout)
if !ok {
t.Fatalf("no deny-write ACE for the git carveout; plan = %+v", plan.Entries)
}
if !entry.Materialize {
t.Fatal("git carveout deny-write is not materialized, so it is skipped on a workspace where .git does not exist yet")
}
}
// Setup must grant the principal the same runtime root that commands write to.
//
// permissionProfileWithRuntime appends this root to WriteRoots on every command
// and redirects HOME, GOCACHE and npm_config_cache into it, but it lives under
// the user cache rather than the workspace, so the profile setup sees never
// contains it. A principal is a separate account with no rights there, so
// without a grant every npm install or go build fails on a cache write.
//
// The assertion that matters is that the two derivations agree. If they drift,
// setup writes the ACE on one directory while commands use another, and the
// symptom is a bare ACCESS_DENIED with nothing pointing at the sandbox.
func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "ws")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{
WorkspaceRoots: []string{workspace},
})
if err != nil {
t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err)
}
if len(granted) == 0 {
t.Fatal("no runtime root resolved for a configured workspace")
}
// EVERY granted candidate has to exist, not just the one this process would
// pick: applyWindowsACLPlan fails the whole run on a target that is absent.
for _, root := range granted {
if info, err := os.Stat(root); err != nil || !info.IsDir() {
t.Fatalf("runtime root %q was not created; applyWindowsACLPlan skips absent targets so the grant would no-op (stat err %v)", root, err)
}
}
// What a command would actually use.
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
if release != nil {
defer release()
}
// The command's choice has to be one setup provisioned. Setup grants the whole
// candidate set precisely because the choice is made per process, so the
// contract is membership rather than equality.
if !grantedRuntimeRootsCover(granted, runtimeState.Root) {
t.Fatalf("setup granted %q but commands write to %q", granted, runtimeState.Root)
}
}
// grantedRuntimeRootsCover reports whether the root a command selected is one of
// the roots setup granted an ACE on.
func grantedRuntimeRootsCover(granted []string, selected string) bool {
for _, root := range granted {
if filepath.Clean(root) == filepath.Clean(selected) {
return true
}
}
return false
}
// No workspace root means nothing to grant, which is not an error.
func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) {
granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{})
if err != nil {
t.Fatalf("no workspace root should not error: %v", err)
}
if len(granted) != 0 {
t.Fatalf("granted %q with no workspace configured", granted)
}
}
// An account in a privileged group must not be adopted, even when name and
// ownership comment say it is ours.
//
// Adoption resets the password and hands the account to the sandbox. If that
// account is also in Administrators, the sandbox gains the rights the sandbox
// exists to withhold: rewriting the ACLs confining it, reading the secret locked
// to the invoking user, and stopping Zero.
func TestProvisionWindowsSandboxIdentityRefusesPrivilegedAccount(t *testing.T) {
stubWindowsProvisioning(t, true, nil, nil, nil)
previous := windowsSandboxUserIsPrivilegedFn
t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous })
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return true, nil }
_, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
if !errors.Is(err, errWindowsSandboxPrivilegedAccount) {
t.Fatalf("provisioning adopted a privileged account, err = %v", err)
}
if created {
t.Fatal("created must stay false for an account this run refused to adopt")
}
}
// The ordinary adopted account is unaffected.
func TestProvisionWindowsSandboxIdentityAdoptsUnprivilegedAccount(t *testing.T) {
stubWindowsProvisioning(t, true, nil, nil, nil)
previous := windowsSandboxUserIsPrivilegedFn
t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous })
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
t.Fatalf("an unprivileged managed account must still be adopted: %v", err)
}
}
@@ -1,92 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"testing"
"golang.org/x/sys/windows"
)
// Group membership is not frozen at setup. An account provisioned clean can be
// added to Administrators afterwards — by an operator, or by an attacker who
// already has that access and wants the sandbox to hand it back. Provisioning's
// refusal cannot see that; only the path that mints the token can.
func TestLookupPrincipalForCommandRefusesAnAccountThatBecamePrivileged(t *testing.T) {
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
t.Fatalf("CreateWellKnownSid: %v", err)
}
restoreLookupSeams(t, sid)
privilegedCalls := 0
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) {
privilegedCalls++
return true, nil
}
_, err = lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline)
if !errors.Is(err, errWindowsSandboxPrivilegedAccount) {
t.Fatalf("err = %v, want errWindowsSandboxPrivilegedAccount", err)
}
if privilegedCalls != 1 {
t.Errorf("privileged check ran %d times, want exactly 1", privilegedCalls)
}
// It must be a hard refusal, not the unavailable sentinel — that one is the
// quiet "not provisioned" fallback and would silently drop the sandbox back
// to the restricted token instead of telling the operator.
if errors.Is(err, errWindowsSandboxIdentityUnavailable) {
t.Error("privileged refusal must not read as the not-provisioned fallback")
}
}
func TestLookupPrincipalForCommandAcceptsAnUnprivilegedAccount(t *testing.T) {
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
t.Fatalf("CreateWellKnownSid: %v", err)
}
restoreLookupSeams(t, sid)
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline)
if err != nil {
t.Fatalf("lookupWindowsSandboxPrincipalForCommand: %v", err)
}
if identity.Username == "" {
t.Error("expected the resolved principal")
}
}
// Teardown must stay able to clean up an account that has become privileged.
// If the refusal lived inside lookupWindowsSandboxIdentity, the account this
// guard exists to catch would become undeletable by Zero.
func TestLookupIdentityItselfDoesNotConsultPrivilege(t *testing.T) {
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
t.Fatalf("CreateWellKnownSid: %v", err)
}
restoreLookupSeams(t, sid)
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) {
t.Error("teardown's lookup must not be gated on privilege")
return true, nil
}
if _, err := lookupWindowsSandboxIdentity("workspace-key", windowsSandboxRoleOnline); err != nil {
t.Fatalf("lookupWindowsSandboxIdentity: %v", err)
}
}
func restoreLookupSeams(t *testing.T, sid *windows.SID) {
t.Helper()
prevResolve := resolveWindowsSandboxSIDFn
prevManaged := windowsSandboxUserIsManagedFn
prevPrivileged := windowsSandboxUserIsPrivilegedFn
t.Cleanup(func() {
resolveWindowsSandboxSIDFn = prevResolve
windowsSandboxUserIsManagedFn = prevManaged
windowsSandboxUserIsPrivilegedFn = prevPrivileged
})
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { return sid, nil }
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
}
@@ -1,187 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"testing"
"golang.org/x/sys/windows"
)
// stubWindowsProvisioning replaces the four provisioning syscalls so the
// function can run on an ordinary machine. Every one of them needs an elevated
// caller and a real local account, so without this the test would stop at the
// first call and never reach the behaviour it is named for.
func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, offlineErr error, sidErr error) {
t.Helper()
prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn
prevOfflineGroup := ensureWindowsSandboxOfflineGroupFn
prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn
prevManaged := windowsSandboxUserIsManagedFn
prevOffline := addWindowsSandboxUserToOfflineGroupFn
prevReset := resetWindowsSandboxUserPasswordFn
t.Cleanup(func() {
ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser
ensureWindowsSandboxOfflineGroupFn = prevOfflineGroup
addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID
windowsSandboxUserIsManagedFn = prevManaged
addWindowsSandboxUserToOfflineGroupFn = prevOffline
resetWindowsSandboxUserPasswordFn = prevReset
})
ensureWindowsSandboxGroupFn = func() error { return nil }
ensureWindowsSandboxOfflineGroupFn = func() error { return nil }
// Adopted accounts are ours in these tests; the ownership check is a real
// syscall and would otherwise refuse before the code under test runs.
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
// Stubbed even though provisioning no longer rotates: rotation moved to the
// caller, so nothing under test reaches this today. Left in so that if it
// ever moves back, these tests fail rather than resetting the password of a
// real managed account that happens to exist on the machine running them.
resetWindowsSandboxUserPasswordFn = func(string, string) error {
t.Fatal("provisioning must not rotate an account password; rotation belongs to the caller, immediately before the secret is written")
return nil
}
ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return existed, nil }
addWindowsSandboxUserToGroupFn = func(string) error { return groupErr }
addWindowsSandboxUserToOfflineGroupFn = func(string) error { return offlineErr }
resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) {
if sidErr != nil {
return nil, sidErr
}
return windows.CreateWellKnownSid(windows.WinLocalSystemSid)
}
}
// A failure after the account has been created must still hand back the name.
//
// The caller's rollback deletes by identity.Username, so returning a zero
// identity alongside created=true asked it to delete "" and quietly left the
// account this run had just made. Group attachment is the case that matters
// most: it is the enforcement boundary and it can fail under local policy.
func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) {
groupFailure := errors.New("group attachment refused by policy")
offlineFailure := errors.New("offline group attachment refused by policy")
sidFailure := errors.New("sid lookup failed")
for name, testCase := range map[string]struct {
groupErr error
offlineErr error
sidErr error
}{
"group attachment fails": {groupErr: groupFailure},
"offline group attachment fails": {offlineErr: offlineFailure},
"sid resolution fails": {sidErr: sidFailure},
} {
t.Run(name, func(t *testing.T) {
stubWindowsProvisioning(t, false, testCase.groupErr, testCase.offlineErr, testCase.sidErr)
identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
if err == nil {
t.Fatal("provisioning reported success despite an injected failure")
}
if !created {
t.Fatal("created = false, so the rollback would skip an account this run made")
}
// The whole point: without a name there is nothing to delete.
if identity.Username == "" {
t.Fatal("identity carries no username, so the rollback deletes \"\" and strands the account")
}
if want := windowsSandboxUserName("workspacekey", windowsSandboxRoleOffline); identity.Username != want {
t.Fatalf("username = %q, want %q", identity.Username, want)
}
})
}
}
// An account that already existed must not be deleted because a later step
// failed. created=false is what stops the rollback turning a partial failure
// into the loss of a working principal from an earlier setup.
func TestProvisionWindowsSandboxIdentityDoesNotClaimPreexistingAccount(t *testing.T) {
stubWindowsProvisioning(t, true, errors.New("group attachment refused"), nil, nil)
_, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
if err == nil {
t.Fatal("provisioning reported success despite an injected failure")
}
// created is the whole assertion. It is what stops the rollback deleting an
// account it did not make, and it must stay false however provisioning
// fails. The identity is deliberately not checked: an adopted account exits
// early at the ownership check, which is a real syscall and not stubbed
// here, so asserting on the name would be testing the stub rather than the
// contract.
if created {
t.Fatal("created = true for an account this run did not create; rollback would delete a working principal")
}
}
// The write grant has to include delete.
//
// FILE_GENERIC_WRITE covers creating and modifying but not removing or
// renaming, and a rename needs delete on the source. The old same-user token hid
// this because the caller already held inherited rights on its own tree; a
// principal is a separate account with none, so without these it can write files
// it can never remove, which fails ordinary editing and most git operations
// rather than an edge case.
func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) {
mode, mask, err := windowsACLAccess(WindowsACLAllowWrite)
if err != nil {
t.Fatalf("windowsACLAccess: %v", err)
}
if mode != windows.GRANT_ACCESS {
t.Fatalf("mode = %v, want GRANT_ACCESS", mode)
}
// Atomic bits only. FILE_GENERIC_READ and FILE_GENERIC_WRITE both carry
// READ_CONTROL and SYNCHRONIZE, so testing a composite constant with & is
// satisfied by any grant at all and proves nothing.
for label, bit := range map[string]windows.ACCESS_MASK{
"DELETE": windows.DELETE,
"FILE_WRITE_DATA": windows.FILE_WRITE_DATA,
} {
if mask&bit == 0 {
t.Errorf("write grant is missing %s", label)
}
}
// Granting these would let the principal rewrite the very restrictions
// placed on it. They are in the deny mask for that reason and must not
// appear here.
//
// FILE_DELETE_CHILD belongs in this set and was originally in the one
// above, on the reasoning that the grant should mirror the deny mask. On a
// parent it authorises deleting a child whatever the child's own DACL says,
// so on a write root it hands back the write-denied carve-outs underneath:
// delete .git/config, recreate it, and the replacement inherits the grant
// with no deny of its own. Mirroring the deny mask is the wrong instinct —
// denying a capability is not a reason to grant it.
for label, bit := range map[string]windows.ACCESS_MASK{
"WRITE_DAC": windows.WRITE_DAC,
"WRITE_OWNER": windows.WRITE_OWNER,
"FILE_DELETE_CHILD": windowsFileDeleteChild,
} {
if mask&bit != 0 {
t.Errorf("write grant unexpectedly includes %s", label)
}
}
}
// The read grant must stay read-only. Widening the write mask above is only
// safe if this one did not move with it.
func TestWindowsACLAllowReadGrantsNoDelete(t *testing.T) {
_, mask, err := windowsACLAccess(WindowsACLAllowRead)
if err != nil {
t.Fatalf("windowsACLAccess: %v", err)
}
// Atomic bits, for the same reason as above: the read and write composites
// overlap on the standard rights, so a composite check here would report a
// failure that is not real.
for label, bit := range map[string]windows.ACCESS_MASK{
"DELETE": windows.DELETE,
"FILE_DELETE_CHILD": windowsFileDeleteChild,
"FILE_WRITE_DATA": windows.FILE_WRITE_DATA,
} {
if mask&bit != 0 {
t.Errorf("read grant unexpectedly includes %s", label)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,292 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"strings"
"sync"
"testing"
)
// An opted-in command that ends up on the restricted token anyway must say so.
// Both cases below are correct fallbacks, not errors — but silence leaves the
// operator believing an account boundary is isolating them when it is not, which
// is the same failure the setup-protocol opt-in check exists to prevent, reached
// from the other side. The deny case matters most: deny is the DEFAULT network
// mode, so a fully provisioned, fully agreeing setup still never uses the
// principal for an ordinary command.
func TestWindowsSandboxPrincipalFallbackIsAnnounced(t *testing.T) {
testCases := []struct {
name string
mode NetworkMode
reason string
}{
// The network-deny case is deliberately absent. It used to warn here, but
// this runner is re-exec'd per command, so the sync.Once guarding the notice
// is once per COMMAND — and deny is the default mode, so the warning landed
// on nearly every tool call. That fact belongs to `zero doctor` now, which is
// read once. The deny-mode BEHAVIOUR is still pinned, by
// TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied below.
{name: "no principal provisioned on this machine", mode: NetworkAllow, reason: "no sandbox principal is provisioned"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
var warned []string
originalWarn := warnWindowsSandboxPrincipalNotUsed
warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) }
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
t.Cleanup(func() {
warnWindowsSandboxPrincipalNotUsed = originalWarn
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
})
config := WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
CommandCWD: `C:\workspace`,
WorkspaceRoots: []string{`C:\workspace`},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
Network: NetworkPolicy{Mode: testCase.mode},
},
Env: map[string]string{windowsSandboxIdentityEnv: "1"},
SandboxLevel: WindowsSandboxLevelRestrictedToken,
Command: []string{"cmd.exe", "/c", "echo"},
}
// Assert the precondition rather than assume it: this must be the quiet
// fallback path, not a token this host actually minted and not an error.
token, ok, err := windowsSandboxPrincipalToken(config)
if ok {
token.Close()
t.Fatalf("host unexpectedly provisioned a principal; this test cannot measure the fallback")
}
if err != nil {
t.Fatalf("windowsSandboxPrincipalToken error = %v, want the quiet fallback", err)
}
if len(warned) != 1 {
t.Fatalf("opted-in fallback warnings = %v, want exactly one naming %q", warned, testCase.reason)
}
if !strings.Contains(warned[0], testCase.reason) {
t.Fatalf("warning = %q, want it to name %q", warned[0], testCase.reason)
}
})
}
}
// The opt-out must stay silent, or the warning becomes noise every user learns
// to ignore.
func TestWindowsSandboxPrincipalFallbackIsSilentWhenOptedOut(t *testing.T) {
var warned []string
originalWarn := warnWindowsSandboxPrincipalNotUsed
warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) }
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
t.Cleanup(func() {
warnWindowsSandboxPrincipalNotUsed = originalWarn
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
})
config := WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
CommandCWD: `C:\workspace`,
WorkspaceRoots: []string{`C:\workspace`},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
Network: NetworkPolicy{Mode: NetworkDeny},
},
Env: map[string]string{windowsSandboxIdentityEnv: "0"},
SandboxLevel: WindowsSandboxLevelRestrictedToken,
Command: []string{"cmd.exe", "/c", "echo"},
}
if _, ok, err := windowsSandboxPrincipalToken(config); ok || err != nil {
t.Fatalf("windowsSandboxPrincipalToken ok=%v err=%v, want the quiet opted-out fallback", ok, err)
}
if len(warned) != 0 {
t.Fatalf("opted-out command warned %v, want silence", warned)
}
}
// Setup must stay inert unless the principal backend is explicitly opted into.
// This is the property that makes the branch safe to merge while the privileged
// paths are still being validated: without the opt-in, `zero sandbox setup`
// creates no local account and the capability-SID backend is the whole of setup.
func TestWindowsSandboxIdentityGating(t *testing.T) {
for name, testCase := range map[string]struct {
env map[string]string
want bool
}{
// An explicit map entry is authoritative; these cases never reach the
// process environment.
"empty": {env: map[string]string{windowsSandboxIdentityEnv: ""}, want: false},
"zero": {env: map[string]string{windowsSandboxIdentityEnv: "0"}, want: false},
"true not one": {env: map[string]string{windowsSandboxIdentityEnv: "true"}, want: false},
"one": {env: map[string]string{windowsSandboxIdentityEnv: "1"}, want: true},
"one with space": {env: map[string]string{windowsSandboxIdentityEnv: " 1 "}, want: true},
} {
t.Run(name, func(t *testing.T) {
// Pin the process variable too. Every case here supplies an explicit
// map entry so none of them should consult it, and pinning proves
// that rather than assuming it: without this a developer who exports
// the opt-in would see different results from CI.
t.Setenv(windowsSandboxIdentityEnv, "1")
if got := windowsSandboxIdentityEnabled(testCase.env); got != testCase.want {
t.Fatalf("enabled = %v, want %v for %q", got, testCase.want, testCase.env[windowsSandboxIdentityEnv])
}
})
}
}
// With no map entry the process environment decides. That fallback is what the
// elevated setup path actually runs on — it passes no Env — so it needs its own
// coverage rather than riding on a case that also has a map entry.
func TestWindowsSandboxIdentityGatingFallsBackToTheProcessEnvironment(t *testing.T) {
for name, testCase := range map[string]struct {
value string
set bool
want bool
}{
"unset": {set: false, want: false},
"empty": {value: "", set: true, want: false},
"zero": {value: "0", set: true, want: false},
"one": {value: "1", set: true, want: true},
"one with space": {value: " 1 ", set: true, want: true},
} {
t.Run(name, func(t *testing.T) {
// t.Setenv registers the restore even when the variable is then
// cleared, which is the only way to test a genuinely absent variable
// without leaking that state into the rest of the package.
t.Setenv(windowsSandboxIdentityEnv, testCase.value)
if !testCase.set {
if err := os.Unsetenv(windowsSandboxIdentityEnv); err != nil {
t.Fatalf("unset %s: %v", windowsSandboxIdentityEnv, err)
}
}
if got := windowsSandboxIdentityEnabled(nil); got != testCase.want {
t.Fatalf("enabled = %v, want %v (set=%v value=%q)", got, testCase.want, testCase.set, testCase.value)
}
})
}
}
// The command environment wins over the process environment, so a run can opt in
// or out without depending on how the parent shell was launched.
func TestWindowsSandboxIdentityEnvOverridesProcess(t *testing.T) {
t.Setenv(windowsSandboxIdentityEnv, "1")
if windowsSandboxIdentityEnabled(map[string]string{windowsSandboxIdentityEnv: "0"}) {
t.Fatal("command env set to 0 must override a process env of 1")
}
if !windowsSandboxIdentityEnabled(map[string]string{}) {
t.Fatal("with no command-env entry the process env should apply")
}
}
// One workspace maps to one principal, and different workspaces must not share
// an account, or two projects would run under the same identity and could reach
// each other's granted roots.
func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) {
first := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`})
again := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`})
other := windowsSandboxWorkspaceKey([]string{`C:\ws\beta`})
if first != again {
t.Fatalf("key is not stable: %q vs %q", first, again)
}
if first == other {
t.Fatal("two different workspaces produced the same principal key")
}
if first == "" {
t.Fatal("empty key")
}
// An empty root list still has to yield a usable key rather than a blank one.
if windowsSandboxWorkspaceKey(nil) == "" {
t.Fatal("no workspace roots produced an empty key")
}
}
// Network denial is enforced by WFP filters keyed to the offline-marker SID,
// which only the restricted token carries. A principal token would leave those
// filters matching nothing, so the principal backend must stand down whenever
// the network is denied rather than silently trading network enforcement for
// read confinement.
// The backend now runs under both network modes, so the opt-in is the only thing
// eligibility turns on. The mode selects which principal is used, not whether one
// is used at all.
func TestPrincipalBackendEligibilityTurnsOnlyOnTheOptIn(t *testing.T) {
eligible := func(mode NetworkMode, optIn string) bool {
config := WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Env: map[string]string{windowsSandboxIdentityEnv: optIn},
}
config.PermissionProfile.Network.Mode = mode
return windowsSandboxPrincipalEligible(config)
}
for _, mode := range []NetworkMode{NetworkDeny, NetworkAllow} {
if !eligible(mode, "1") {
t.Fatalf("principal backend refused with network %q; the mode should choose a principal, not disable the backend", mode)
}
if eligible(mode, "0") {
t.Fatalf("principal backend eligible without the opt-in for network %q", mode)
}
}
}
// The network mode is enforced by WHICH principal runs, because the offline one
// is a member of the group the block filters match. Picking the wrong role is
// therefore a silent loss of network enforcement, so the mapping is asserted
// directly rather than inferred from a token that only exists on a provisioned
// machine.
func TestPrincipalRoleFollowsNetworkMode(t *testing.T) {
if got := windowsSandboxRoleForNetwork(NetworkDeny); got != windowsSandboxRoleOffline {
t.Fatalf("deny selected %q, want the offline principal; the online one is not in the blocked group and would have the network", got)
}
if got := windowsSandboxRoleForNetwork(NetworkAllow); got != windowsSandboxRoleOnline {
t.Fatalf("allow selected %q, want the online principal", got)
}
// Fail closed. An unrecognised mode must lose the network rather than keep it,
// so anything that is not an explicit allow maps to the offline principal.
for _, mode := range []NetworkMode{NetworkMode(""), NetworkMode("bogus"), NetworkMode("ALLOW")} {
if got := windowsSandboxRoleForNetwork(mode); got != windowsSandboxRoleOffline {
t.Fatalf("unrecognised mode %q selected %q, want the offline principal so an unknown mode fails closed", mode, got)
}
}
}
// The account key has to separate USERS, not only workspaces.
//
// The name is machine-wide while the DPAPI secret and the ACL ledger both live
// under the invoking user's sandbox home. Keyed on the workspace alone, two
// Windows users sharing one path derive the same account with private
// bookkeeping: the second finds no ledger of its own and either retires the
// first user's working principal or adopts it and rotates the password while
// storing only its own secret. The first user's marker still validates and its
// next command cannot log on.
//
// Asserting the principal key DIFFERS from the workspace key is what pins that.
// It is the cheapest observable difference; a test that only checked the key was
// stable would pass against the broken version.
func TestPrincipalKeyIsScopedToTheUserNotOnlyTheWorkspace(t *testing.T) {
roots := []string{`C:\ws\shared`}
workspace := windowsSandboxWorkspaceKey(roots)
config := WindowsSandboxCommandConfig{WorkspaceRoots: roots}
principal := windowsSandboxPrincipalKey(config)
if principal == workspace {
t.Fatal("the principal key equals the workspace key, so two users sharing this workspace would derive one account with separate secrets and ledgers")
}
if principal != windowsSandboxPrincipalKey(config) {
t.Error("the principal key is not stable for one user, so a second setup would not find its own account")
}
if other := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: []string{`C:\ws\other`}}); other == principal {
t.Error("two workspaces derived the same principal key, so they would share one account")
}
}
// The setup lock must stay keyed to the WORKSPACE alone. Two users setting up
// one shared workspace now provision separate accounts, but they still write
// DACLs on the same paths, so they have to keep serializing against each other.
func TestSetupLockStaysWorkspaceScoped(t *testing.T) {
roots := []string{`C:\ws\shared`}
if windowsSandboxWorkspaceKey(roots) == windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots}) {
t.Fatal("the lock key and the principal key are the same, so making the account per-user also made the lock per-user")
}
}
@@ -1,119 +0,0 @@
//go:build windows
package sandbox
import (
"fmt"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// createWindowsSecretFileNoFollow creates (or replaces) the secret file as a
// CHILD of a pinned parent handle and returns a handle to it.
//
// The pathname version this replaces resolved the path four separate times in an
// elevated process: MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then
// WriteFile by name. The sandbox home belongs to the invoking user, who is the
// party this sandbox exists to contain, so each of those resolutions was a place
// to swap a component. Worst case an attacker-chosen file gets truncated and then
// has its DACL rewritten by an Administrator; the milder junction-only case still
// plants the deterministic secret somewhere the caller then owns.
//
// One handle, one resolution. The parent is opened no-follow, the leaf is created
// relative to that handle so its name can never be resolved again, and both the
// DACL and the bytes are applied to the handle rather than to a path.
//
// FILE_OVERWRITE_IF, not FILE_CREATE: an existing secret must be replaced, since
// a stale password makes LogonUser fail in a way that reads as a sandbox bug.
// FILE_OPEN_REPARSE_POINT means a leaf that IS a reparse point comes back as
// itself rather than being followed, and the attribute check below then refuses
// it instead of writing through it.
func createWindowsSecretFileNoFollow(path string) (windows.Handle, error) {
parentPath := filepath.Dir(path)
name := filepath.Base(path)
if err := validateWindowsACLComponent(name); err != nil {
return 0, fmt.Errorf("secret file name: %w", err)
}
parent, err := openWindowsACLDirectoryNoFollow(parentPath)
if err != nil {
return 0, fmt.Errorf("open secret directory %s: %w", parentPath, err)
}
defer func() { _ = windows.CloseHandle(parent) }()
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, fmt.Errorf("encode secret file name %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
var handle windows.Handle
var status windows.IO_STATUS_BLOCK
if err := windows.NtCreateFile(
&handle,
// WRITE_DAC to lock it down and FILE_WRITE_DATA to fill it, both through
// this one handle. READ_CONTROL because SetSecurityInfo reads the existing
// descriptor before replacing it, and FILE_READ_ATTRIBUTES because the
// reparse-point check below asks the handle what it landed on.
windows.FILE_WRITE_DATA|windows.FILE_READ_ATTRIBUTES|windows.WRITE_DAC|windows.READ_CONTROL|windows.SYNCHRONIZE,
&attributes,
&status,
nil,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_SHARE_READ,
windows.FILE_OVERWRITE_IF,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
0,
0,
); err != nil {
return 0, fmt.Errorf("create secret file %s: %w", path, err)
}
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
_ = windows.CloseHandle(handle)
return 0, err
}
return handle, nil
}
// lockWindowsSecretHandleToOwner applies the owner-and-SYSTEM-only DACL to an
// open handle instead of to a pathname, so the object being locked is provably
// the object that was just created rather than whatever the name resolves to now.
func lockWindowsSecretHandleToOwner(handle windows.Handle, owner *windows.SID) error {
acl, err := windowsSecretOwnerACL(owner)
if err != nil {
return err
}
if err := windows.SetSecurityInfo(
handle,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
acl,
nil,
); err != nil {
return fmt.Errorf("lock secret to owner: %w", err)
}
return nil
}
// writeWindowsSecretHandle writes the whole payload through the handle.
func writeWindowsSecretHandle(handle windows.Handle, payload []byte) error {
for written := 0; written < len(payload); {
var n uint32
if err := windows.WriteFile(handle, payload[written:], &n, nil); err != nil {
return fmt.Errorf("write secret: %w", err)
}
if n == 0 {
return fmt.Errorf("write secret: wrote no bytes with %d remaining", len(payload)-written)
}
written += int(n)
}
return nil
}
@@ -1,71 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
// THE SECRET MUST NOT BE WRITTEN THROUGH A REPARSE POINT.
//
// The sandbox home belongs to the invoking user, who is the party this sandbox
// exists to contain, so they can put a junction where the secret directory is
// expected before elevated setup runs. The pathname version resolved the path
// four times over (MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then
// WriteFile by name) and every one of them followed it, so an Administrator
// process created the principal's password in a directory of the caller's
// choosing and then granted them control of it.
//
// A junction rather than a symlink on purpose: it needs no privilege, so it is
// reachable by exactly the unprivileged user this guards against. os.Symlink
// would need SeCreateSymbolicLinkPrivilege and would skip on an ordinary
// developer or CI account, which is where this most needs to run.
func TestWriteWindowsSandboxSecretRefusesAJunctionedDirectory(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(base, "outside")
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
// The swap: the directory the secret is addressed into is a junction that
// leads out of the sandbox home. The pathname never changes.
cfg := filepath.Join(base, "cfg")
makeJunction(t, cfg, outside)
path := filepath.Join(cfg, "zero-sbx-test.secret")
err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE")
if err == nil {
t.Fatal("wrote the principal secret through a junction, so an elevated setup would have placed it in a directory the caller controls")
}
if !strings.Contains(err.Error(), "reparse") {
t.Fatalf("refused for the wrong reason: %v", err)
}
// Refusing is only half of it. Nothing may survive on the other side, or the
// caller still ends up holding a file the sandbox created for them.
entries, readErr := os.ReadDir(outside)
if readErr != nil {
t.Fatalf("ReadDir: %v", readErr)
}
if len(entries) != 0 {
t.Fatalf("junction target holds %d entries after the refusal, want none", len(entries))
}
}
// The ordinary path must still work, or the refusal above proves only that the
// function fails.
func TestWriteWindowsSandboxSecretStillWritesAnOrdinaryDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
const password = "Zs1!EXAMPLEPASSWORDVALUE"
if err := writeWindowsSandboxSecret(path, password); err != nil {
t.Fatalf("write: %v", err)
}
got, err := readWindowsSandboxSecret(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if got != password {
t.Fatalf("read %q, want the stored password", got)
}
}
@@ -1,257 +0,0 @@
//go:build windows
package sandbox
// Storing a sandbox principal's password.
//
// The elevated setup path provisions the account, but the per-command path runs
// UNELEVATED and needs the password to call LogonUser. So the secret has to
// cross that boundary on disk, and the only thing standing between it and the
// sandboxed child is the file's ACL.
//
// The file is locked to the invoking user: an explicit, INHERITANCE-PROTECTED
// DACL granting that user and SYSTEM, and nobody else. The sandbox principal is
// deliberately absent from it, which is the property that matters, because a
// principal that could read this file could mint its own token and the whole
// identity boundary would be decorative. Administrators are not added either;
// an admin can already take ownership, so naming them buys nothing and widens
// the visible grant.
//
// Ordering is load-bearing: the ACL is applied to an EMPTY file before the
// password is written, so the bytes never exist under the directory's inherited
// permissions even briefly.
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
// windowsSandboxSecretDirName holds per-principal secrets under the Zero config
// directory. Kept in its own directory so the whole set can be removed when the
// sandbox is torn down.
const windowsSandboxSecretDirName = "windows-sandbox"
// windowsSandboxSecretPath returns where a principal's password lives. The
// account name is already sanitised to [a-z0-9-] by windowsSandboxUserName, so
// it cannot escape the directory.
func windowsSandboxSecretPath(configDir string, username string) (string, error) {
if strings.TrimSpace(configDir) == "" {
return "", errors.New("windows sandbox secret: empty config directory")
}
if strings.TrimSpace(username) == "" {
return "", errors.New("windows sandbox secret: empty principal name")
}
// Defence in depth against a caller passing something windowsSandboxUserName
// did not produce: refuse anything with a separator or a parent reference.
if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") {
return "", fmt.Errorf("windows sandbox secret: unsafe principal name %q", username)
}
return filepath.Join(configDir, windowsSandboxSecretDirName, username+".secret"), nil
}
// currentTokenUserSID returns the SID of the user this process runs as. Under
// UAC the elevated token keeps the same user SID as the desktop session, so
// setup and the later unelevated command path agree on the owner, which is what
// makes an owner-scoped ACL usable across the elevation boundary.
func currentTokenUserSID() (*windows.SID, error) {
var token windows.Token
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
return nil, fmt.Errorf("open process token: %w", err)
}
defer token.Close()
user, err := token.GetTokenUser()
if err != nil {
return nil, fmt.Errorf("get token user: %w", err)
}
// The SID points into a buffer owned by the Tokenuser, so copy it out before
// that buffer goes away.
copied, err := user.User.Sid.Copy()
if err != nil {
return nil, fmt.Errorf("copy token user SID: %w", err)
}
return copied, nil
}
// windowsSecretOwnerACL builds the explicit, inheritance-protected DACL granting
// only owner and SYSTEM. PROTECTED is what drops any ACE inherited from the
// config directory; without it a permissive parent would still grant access to
// whoever it names.
//
// Returns the ACL rather than applying it, because the caller applies it to an
// open HANDLE. Applying by pathname meant the object being locked was whatever
// the name resolved to at that instant, which is not necessarily the object that
// was just created.
func windowsSecretOwnerACL(owner *windows.SID) (*windows.ACL, error) {
if owner == nil {
return nil, errors.New("windows sandbox secret: nil owner SID")
}
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
return nil, fmt.Errorf("resolve SYSTEM SID: %w", err)
}
entries := []windows.EXPLICIT_ACCESS{
{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.NO_INHERITANCE,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_USER,
TrusteeValue: windows.TrusteeValueFromSID(owner),
},
},
{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.NO_INHERITANCE,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_USER,
TrusteeValue: windows.TrusteeValueFromSID(system),
},
},
}
acl, err := windows.ACLFromEntries(entries, nil)
if err != nil {
return nil, fmt.Errorf("build secret ACL: %w", err)
}
return acl, nil
}
// writeWindowsSandboxSecret stores a principal's password readable only by the
// invoking user.
//
// The file is created empty, locked down, and only then written, so the secret
// is never on disk under the directory's inherited ACL. An existing file is
// replaced rather than appended, since a stale password would make LogonUser
// fail in a way that looks like a sandbox bug.
func writeWindowsSandboxSecret(path string, password string) error {
owner, err := currentTokenUserSID()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create secret directory: %w", err)
}
// Sealed BEFORE the file exists, so a failure here leaves nothing on disk at
// all rather than an empty file for someone else to win a race on.
//
// Encrypted to the invoking user on top of the ACL, so a copy taken outside
// the filesystem's enforcement (backup, disk image) is inert. The principal
// name is the entropy, which keeps one principal's blob from authenticating
// another.
sealed, err := protectWindowsSecret(password, windowsSandboxSecretEntropy(path))
if err != nil {
return err
}
// ONE resolution of the path, not four. The leaf is created relative to a
// pinned no-follow parent handle, and the DACL and the bytes are both applied
// to that handle. Resolving the name again between those steps is what let a
// junction or symlink swap point an elevated write at a file of the caller's
// choosing.
handle, err := createWindowsSecretFileNoFollow(path)
if err != nil {
return err
}
defer func() { _ = windows.CloseHandle(handle) }()
if err := lockWindowsSecretHandleToOwner(handle, owner); err != nil {
// Do not leave an unprotected file behind. Removal is by name, which is
// safe in a way the write was not: worst case a swapped name means the
// cleanup misses and leaves a locked-down file, never that it deletes
// something it did not create.
_ = os.Remove(path)
return err
}
if err := writeWindowsSecretHandle(handle, sealed); err != nil {
_ = os.Remove(path)
return err
}
return nil
}
// windowsSandboxSecretEntropy derives the DPAPI entropy from the secret's own
// filename, which is the principal name. Deriving it rather than threading the
// name through keeps read and write agreeing by construction.
func windowsSandboxSecretEntropy(path string) string {
return strings.TrimSuffix(filepath.Base(path), ".secret")
}
// Seamed so the permission-denied mapping in readWindowsSandboxSecret is
// testable. Producing a real ERROR_ACCESS_DENIED needs DACL surgery on Windows,
// since a 0000 file is still readable and reading a directory reports
// "Incorrect function", so a test built that way would exercise the platform
// rather than the mapping.
var readWindowsSandboxSecretFile = os.ReadFile
// readWindowsSandboxSecret loads a principal's password. A missing file means
// setup has not run for this workspace, which the caller turns into a fallback
// rather than a hard failure.
func readWindowsSandboxSecret(path string) (string, error) {
data, err := readWindowsSandboxSecretFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", errWindowsSandboxIdentityUnavailable
}
// Permission denied is unavailability, not breakage. The secret's DACL
// names whoever ran setup, so an operator who elevated with a separate
// administrative account, through runas or an over-the-shoulder UAC
// prompt, ends up with a secret their ordinary account cannot open. That
// is the documented fail-soft case: fall back to the restricted token and
// let the warning say so. Treating it as a hard error instead made every
// sandboxed command fail on a machine that was merely set up by a
// different admin, which is a common way to run an elevated setup.
if os.IsPermission(err) {
return "", errWindowsSandboxIdentityUnavailable
}
return "", fmt.Errorf("read sandbox secret: %w", err)
}
if len(data) == 0 {
return "", errWindowsSandboxIdentityUnavailable
}
secret, err := unprotectWindowsSecret(data, windowsSandboxSecretEntropy(path))
if err != nil {
// A blob written by another user, for another principal, or by an older
// build that stored the password in the clear. Report it as unavailable so
// the caller falls back to the restricted token; the next elevated setup
// rewrites the secret in the current format.
return "", errWindowsSandboxIdentityUnavailable
}
if strings.TrimSpace(secret) == "" {
return "", errWindowsSandboxIdentityUnavailable
}
return secret, nil
}
// removeWindowsSandboxSecret deletes a stored password. Called before the
// account itself is removed so a secret never outlives the principal it
// authenticates.
// errWindowsSandboxSecretNotOurs reports a secret this account cannot delete
// because another administrator's setup owns its DACL.
//
// Distinguishable so teardown can carry on. The alternative is what it used to
// do: abort before removing the account, its logon rights, its ACEs and its
// ledger, leaving a fully provisioned principal on the machine because one file
// could not be unlinked.
var errWindowsSandboxSecretNotOurs = errors.New("the sandbox secret belongs to a different administrator's setup")
func removeWindowsSandboxSecret(path string) error {
err := os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
// The read path already treats permission-denied as unavailability rather
// than breakage, for the reason documented on readWindowsSandboxSecret: an
// operator who elevated with a separate administrative account gets a secret
// their ordinary account cannot open. Removal has to agree. It did not, so on
// exactly those machines teardown stopped at the first file and stranded
// everything after it.
if os.IsPermission(err) {
return fmt.Errorf("%w: %s", errWindowsSandboxSecretNotOurs, path)
}
return fmt.Errorf("remove sandbox secret: %w", err)
}
@@ -1,300 +0,0 @@
//go:build windows
package sandbox
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
func TestWindowsSandboxSecretRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
const password = "Zs1!EXAMPLEPASSWORDVALUE"
if err := writeWindowsSandboxSecret(path, password); err != nil {
t.Fatalf("write: %v", err)
}
got, err := readWindowsSandboxSecret(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if got != password {
t.Fatalf("read %q, want the stored password", got)
}
}
// THE security property: the stored password must be readable only by the user
// who owns it. If any other trustee appears in the DACL, and in particular the
// sandbox principal, that account could mint its own token and the identity
// boundary would be worthless.
func TestWindowsSandboxSecretIsLockedToOwner(t *testing.T) {
path := filepath.Join(t.TempDir(), "locked.secret")
if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil {
t.Fatalf("write: %v", err)
}
descriptor, err := windows.GetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
t.Fatalf("read back security info: %v", err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
t.Fatalf("read DACL: %v", err)
}
if dacl == nil {
t.Fatal("secret has a nil DACL, which grants everyone access")
}
owner, err := currentTokenUserSID()
if err != nil {
t.Fatalf("owner SID: %v", err)
}
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
t.Fatalf("SYSTEM SID: %v", err)
}
entries, err := windowsSecretACEList(dacl)
if err != nil {
t.Fatalf("enumerate ACEs: %v", err)
}
if len(entries) == 0 {
t.Fatal("secret DACL has no ACEs")
}
for _, sid := range entries {
if sid.Equals(owner) || sid.Equals(system) {
continue
}
t.Fatalf("secret DACL grants an unexpected trustee %s; only the owner and SYSTEM may appear", sid)
}
}
// The DACL must be inheritance-protected, otherwise a permissive ACE on the
// config directory would still reach the secret.
func TestWindowsSandboxSecretDaclIsProtected(t *testing.T) {
path := filepath.Join(t.TempDir(), "protected.secret")
if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil {
t.Fatalf("write: %v", err)
}
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Fatalf("read back security info: %v", err)
}
control, _, err := descriptor.Control()
if err != nil {
t.Fatalf("read control bits: %v", err)
}
if control&windows.SE_DACL_PROTECTED == 0 {
t.Fatal("secret DACL is not protected, so inherited ACEs still apply")
}
}
// Rewriting must replace the previous secret rather than append to it, or
// LogonUser would be handed two concatenated passwords.
func TestWindowsSandboxSecretOverwrites(t *testing.T) {
path := filepath.Join(t.TempDir(), "rewrite.secret")
if err := writeWindowsSandboxSecret(path, "Zs1!FIRST"); err != nil {
t.Fatalf("first write: %v", err)
}
if err := writeWindowsSandboxSecret(path, "Zs1!SECOND"); err != nil {
t.Fatalf("second write: %v", err)
}
got, err := readWindowsSandboxSecret(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if got != "Zs1!SECOND" {
t.Fatalf("read %q, want only the newest password", got)
}
}
// A workspace whose setup has not run must report the actionable sentinel so the
// command path falls back to the restricted-token backend instead of failing.
func TestWindowsSandboxSecretMissingIsSentinel(t *testing.T) {
path := filepath.Join(t.TempDir(), "absent.secret")
if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable {
t.Fatalf("missing secret returned %v, want errWindowsSandboxIdentityUnavailable", err)
}
}
// An empty file is a half-written secret, not a valid empty password.
//
// Both seeds matter and only one of them tests what the name says. A
// zero-length file is the truncated-write case, and it is the only one that
// reaches the length check. Whitespace is several bytes, so it travels on to
// DPAPI and fails to unprotect instead, which is the path
// TestWindowsSandboxSecretRejectsLegacyPlaintext already covers. Seeding only
// the whitespace, as this did, left the branch in the test's own name
// unexercised.
func TestWindowsSandboxSecretEmptyIsSentinel(t *testing.T) {
for name, seed := range map[string][]byte{
"truncated write": {},
"whitespace only": []byte(" \r\n"),
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.secret")
if err := os.WriteFile(path, seed, 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable {
t.Fatalf("secret of %d bytes returned %v, want the unavailable sentinel", len(seed), err)
}
})
}
}
// The principal name lands in a filename, so anything that could escape the
// directory has to be refused even though windowsSandboxUserName already
// sanitises its output.
func TestWindowsSandboxSecretPathRejectsTraversal(t *testing.T) {
for _, name := range []string{`..\evil`, "sub/dir", `C:\abs`, "..", ""} {
if _, err := windowsSandboxSecretPath(`C:\cfg`, name); err == nil {
t.Fatalf("principal name %q was accepted", name)
}
}
if _, err := windowsSandboxSecretPath("", "zero-sbx-a"); err == nil {
t.Fatal("empty config directory was accepted")
}
path, err := windowsSandboxSecretPath(`C:\cfg`, "zero-sbx-abc")
if err != nil {
t.Fatalf("valid name rejected: %v", err)
}
if !strings.HasSuffix(path, `zero-sbx-abc.secret`) {
t.Fatalf("unexpected secret path %q", path)
}
}
// Removal must be idempotent so teardown converges the same way provisioning
// does, and must actually delete the secret.
func TestWindowsSandboxSecretRemoveIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "gone.secret")
if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil {
t.Fatalf("write: %v", err)
}
if err := removeWindowsSandboxSecret(path); err != nil {
t.Fatalf("first remove: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("secret still present after removal (stat err %v)", err)
}
if err := removeWindowsSandboxSecret(path); err != nil {
t.Fatalf("removing an absent secret must succeed, got %v", err)
}
}
// windowsSecretACEList returns the trustee SID of every ACE in a DACL so a test
// can assert exactly who is named.
func windowsSecretACEList(dacl *windows.ACL) ([]*windows.SID, error) {
var out []*windows.SID
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, index, &ace); err != nil {
return nil, err
}
// GetAce hands back a generic ACE_HEADER and we reinterpret it. That is
// only sound for the fixed-layout types: an object ACE carries Flags and
// two GUIDs ahead of the trustee, so SidStart would land mid-structure
// and Copy would read whatever bytes happen to be there. The caller's
// "unexpected trustee" assertion would then print a nonsense SID instead
// of naming the ACE that does not belong, which is the opposite of what
// a failing test should do. Nothing under test builds anything but
// allowed ACEs today, so this exists to keep the failure legible if that
// ever changes.
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
return nil, fmt.Errorf("ACE %d has type %d, want ACCESS_ALLOWED_ACE_TYPE (%d); refusing to decode its trustee", index, ace.Header.AceType, windows.ACCESS_ALLOWED_ACE_TYPE)
}
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
copied, err := sid.Copy()
if err != nil {
return nil, err
}
out = append(out, copied)
}
return out, nil
}
// DPAPI round-trip through the real store, which needs no privilege and so is
// genuine coverage rather than a gated stub.
func TestWindowsSandboxSecretRoundTripsThroughDPAPI(t *testing.T) {
path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-roundtrip")
if err != nil {
t.Fatalf("secret path: %v", err)
}
const password = "S0me-Sandbox-P@ssw0rd-value"
if err := writeWindowsSandboxSecret(path, password); err != nil {
t.Fatalf("write secret: %v", err)
}
got, err := readWindowsSandboxSecret(path)
if err != nil {
t.Fatalf("read secret: %v", err)
}
if got != password {
t.Fatalf("round-trip returned %q, want %q", got, password)
}
// The point of the exercise: the password must not be recoverable by reading
// the file, or the encryption layer is decorative.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read raw secret file: %v", err)
}
if bytes.Contains(raw, []byte(password)) {
t.Fatal("the password appears verbatim in the stored file; it was not encrypted")
}
}
// Entropy is the principal name, so a blob moved onto another principal's secret
// path must fail to decrypt rather than authenticate the wrong account.
func TestWindowsSandboxSecretDoesNotTransferBetweenPrincipals(t *testing.T) {
home := t.TempDir()
minePath, err := windowsSandboxSecretPath(home, "zero-sbx-mine")
if err != nil {
t.Fatalf("secret path: %v", err)
}
theirsPath, err := windowsSandboxSecretPath(home, "zero-sbx-theirs")
if err != nil {
t.Fatalf("secret path: %v", err)
}
if err := writeWindowsSandboxSecret(minePath, "a-password-for-mine"); err != nil {
t.Fatalf("write secret: %v", err)
}
blob, err := os.ReadFile(minePath)
if err != nil {
t.Fatalf("read blob: %v", err)
}
if err := os.WriteFile(theirsPath, blob, 0o600); err != nil {
t.Fatalf("plant blob: %v", err)
}
if _, err := readWindowsSandboxSecret(theirsPath); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
t.Fatalf("a blob planted at another principal's path decrypted; got err = %v", err)
}
}
// An older plaintext secret must degrade to a fallback rather than being handed
// to LogonUser as if it were a password.
func TestWindowsSandboxSecretRejectsLegacyPlaintext(t *testing.T) {
path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-legacy")
if err != nil {
t.Fatalf("secret path: %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte("plaintext-password"), 0o600); err != nil {
t.Fatalf("write legacy secret: %v", err)
}
if _, err := readWindowsSandboxSecret(path); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
t.Fatalf("legacy plaintext secret was accepted; got err = %v", err)
}
}
File diff suppressed because it is too large. Load diff
@@ -1,498 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
// A local Windows account name is capped at 20 characters, so the derived name
// must truncate rather than produce a name NetUserAdd rejects.
func TestWindowsSandboxUserNameRespectsLengthLimit(t *testing.T) {
name := windowsSandboxUserName(strings.Repeat("a", 64), windowsSandboxRoleOffline)
if len(name) > windowsSandboxUserNameMax {
t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax)
}
if !strings.HasPrefix(name, windowsSandboxUserPrefix) {
t.Fatalf("name %q lost the managed prefix", name)
}
}
// The same workspace must map to the same principal, otherwise re-running setup
// would accumulate a new local account every time.
func TestWindowsSandboxUserNameIsStable(t *testing.T) {
first := windowsSandboxUserName("abc123", windowsSandboxRoleOffline)
second := windowsSandboxUserName("abc123", windowsSandboxRoleOffline)
if first != second {
t.Fatalf("name is not stable: %q vs %q", first, second)
}
if other := windowsSandboxUserName("def456", windowsSandboxRoleOffline); other == first {
t.Fatalf("different workspaces produced the same principal %q", first)
}
}
// The key is sanitised to characters a local account name accepts, so a hash or
// path fragment cannot smuggle a separator or a space into the name.
func TestWindowsSandboxUserNameRejectsUnsafeCharacters(t *testing.T) {
name := windowsSandboxUserName(`C:\Users\me\proj ect`, windowsSandboxRoleOffline)
for _, r := range strings.TrimPrefix(name, windowsSandboxUserPrefix) {
isLower := r >= 'a' && r <= 'z'
isDigit := r >= '0' && r <= '9'
if !isLower && !isDigit {
t.Fatalf("name %q contains unsafe rune %q", name, r)
}
}
if name == windowsSandboxUserPrefix {
t.Fatal("sanitising removed every character, leaving a bare prefix")
}
}
// An empty or fully-sanitised-away key must still yield a usable name rather
// than the bare prefix.
func TestWindowsSandboxUserNameHandlesEmptyKey(t *testing.T) {
for _, key := range []string{"", "///", " "} {
if got := windowsSandboxUserName(key, windowsSandboxRoleOffline); got == windowsSandboxUserPrefix {
t.Fatalf("key %q produced a bare prefix", key)
}
}
}
// The password must be fresh per call and carry the character classes a default
// Windows complexity policy demands, or NetUserAdd fails with ERROR_PASSWORD_RESTRICTION.
func TestNewWindowsSandboxPasswordIsRandomAndComplex(t *testing.T) {
first, err := newWindowsSandboxPassword()
if err != nil {
t.Fatalf("generate: %v", err)
}
second, err := newWindowsSandboxPassword()
if err != nil {
t.Fatalf("generate: %v", err)
}
if first == second {
t.Fatal("two generated passwords are identical, so they are not random")
}
if len(first) < 12 {
t.Fatalf("password is only %d chars", len(first))
}
var hasUpper, hasLower, hasDigit bool
for _, r := range first {
switch {
case r >= 'A' && r <= 'Z':
hasUpper = true
case r >= 'a' && r <= 'z':
hasLower = true
case r >= '0' && r <= '9':
hasDigit = true
}
}
if !hasUpper || !hasLower || !hasDigit {
t.Fatalf("password %q lacks a required character class", first)
}
}
// "Already exists" is the normal result of re-running setup and must not surface
// as an error, while a genuine failure must.
func TestNetAPIStatusTreatsExistingAsSuccess(t *testing.T) {
if err := netAPIStatus("NetUserAdd", nerrSuccess); err != nil {
t.Fatalf("success status returned %v", err)
}
if err := netAPIStatus("NetUserAdd", nerrUserExists, nerrUserExists); err != nil {
t.Fatalf("existing user must be success, got %v", err)
}
if err := netAPIStatus("NetLocalGroupAdd", nerrGroupExists, nerrGroupExists, errorAliasExists); err != nil {
t.Fatalf("existing group must be success, got %v", err)
}
if err := netAPIStatus("NetUserAdd", 2245); err == nil {
t.Fatal("an unexpected status must surface as an error")
}
}
// Access-denied is the status an unelevated run gets, and it must say so rather
// than reporting a bare number the user cannot act on.
func TestNetAPIStatusExplainsAccessDenied(t *testing.T) {
err := netAPIStatus("NetUserAdd", errorAccessDenied32)
if err == nil {
t.Fatal("access denied must be an error")
}
if !strings.Contains(err.Error(), "elevated") {
t.Fatalf("error %q should point at elevation", err)
}
}
// The Win32 structs are passed to netapi32 as raw buffers, so their layout must
// match what the API expects. A wrong size means silent memory corruption.
func TestWindowsIdentityStructLayouts(t *testing.T) {
ptr := unsafe.Sizeof(uintptr(0))
if got, want := unsafe.Sizeof(localGroupMembersInfo3{}), ptr; got != want {
t.Fatalf("LOCALGROUP_MEMBERS_INFO_3 size = %d, want %d", got, want)
}
if got, want := unsafe.Sizeof(localGroupInfo1{}), 2*ptr; got != want {
t.Fatalf("LOCALGROUP_INFO_1 size = %d, want %d", got, want)
}
// USER_INFO_1 is four pointers plus three DWORDs, with the compiler padding
// each DWORD pair up to pointer alignment on amd64.
if got := unsafe.Sizeof(userInfo1{}); got < 4*ptr {
t.Fatalf("USER_INFO_1 size = %d, smaller than its four pointer fields", got)
}
if unsafe.Offsetof(userInfo1{}.Password) != ptr {
t.Fatal("USER_INFO_1.Password must directly follow Name")
}
}
// LSA_UNICODE_STRING counts BYTES, not runes, and excludes the NUL terminator
// from Length while including it in MaximumLength. Getting either wrong makes
// LsaAddAccountRights read past the buffer or silently match no right, so pin it.
func TestNewLSAStringUsesByteLengths(t *testing.T) {
buffer, err := windows.UTF16FromString("SeBatchLogonRight")
if err != nil {
t.Fatalf("encode: %v", err)
}
entry := newLSAString(buffer)
const runes uint16 = uint16(len("SeBatchLogonRight"))
if entry.Length != runes*2 {
t.Fatalf("Length = %d, want %d (bytes, excluding NUL)", entry.Length, runes*2)
}
if entry.MaximumLength != (runes+1)*2 {
t.Fatalf("MaximumLength = %d, want %d (bytes, including NUL)", entry.MaximumLength, (runes+1)*2)
}
if entry.Buffer == nil {
t.Fatal("Buffer must point at the encoded string")
}
}
// An empty buffer must not produce a struct pointing at nothing with a nonzero
// length, which would hand LSA a wild pointer.
func TestNewLSAStringHandlesEmptyBuffer(t *testing.T) {
entry := newLSAString(nil)
if entry.Buffer != nil || entry.Length != 0 || entry.MaximumLength != 0 {
t.Fatalf("empty buffer produced %+v, want a zero value", entry)
}
}
// The LSA structs are passed to advapi32 as raw buffers, so their sizes must
// match the Win32 definitions.
func TestLSAStructLayouts(t *testing.T) {
ptr := unsafe.Sizeof(uintptr(0))
// LSA_UNICODE_STRING: two uint16 then a pointer, padded to pointer alignment.
if got, want := unsafe.Sizeof(lsaUnicodeString{}), 2*ptr; got != want {
t.Fatalf("LSA_UNICODE_STRING size = %d, want %d", got, want)
}
if unsafe.Offsetof(lsaUnicodeString{}.Buffer) != ptr {
t.Fatal("LSA_UNICODE_STRING.Buffer must sit at the second pointer slot")
}
var attributes lsaObjectAttributes
if unsafe.Sizeof(attributes) < 6*ptr-ptr {
t.Fatalf("LSA_OBJECT_ATTRIBUTES size = %d, smaller than its fields", unsafe.Sizeof(attributes))
}
if unsafe.Offsetof(attributes.ObjectName) == 0 {
t.Fatal("LSA_OBJECT_ATTRIBUTES.ObjectName must not alias Length")
}
}
// Provisioning creates real local accounts, so it only runs when explicitly
// opted into on an elevated machine. Everything above covers the logic that can
// be exercised without touching the account database.
func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) {
if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" {
// Spelled out per shell because `set VAR=1` is cmd syntax and silently
// sets a shell variable rather than an environment variable in
// PowerShell, which makes this skip look like the elevation check failing.
t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)")
}
if !windowsProcessIsElevated() {
t.Skip("provisioning requires an elevated process")
}
// Starting clean keeps a failure here from being explained by residue from a
// previous run. Provisioning no longer resets an adopted account's password,
// so a leftover account would otherwise be adopted with a password this test
// never learns.
_ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01", windowsSandboxRoleOffline), "ziptest01")
identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline)
if err != nil {
t.Fatalf("provision: %v", err)
}
// Registered immediately after provisioning so every failure path below is
// covered. This test grants a real batch-logon right to a real local account;
// leaving either behind on a developer machine is not acceptable residue, and
// rights are revoked before the account so nothing is left keyed to a SID that
// no longer resolves.
t.Cleanup(func() {
if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil {
t.Errorf("cleanup: revoke logon rights: %v", err)
}
if err := removeWindowsSandboxIdentity(identity.Username, "ziptest01"); err != nil && !errors.Is(err, errWindowsSandboxForeignAccountRetained) {
t.Errorf("cleanup: remove principal: %v", err)
}
})
if identity.SID == nil {
t.Fatal("provisioned identity has no SID")
}
if password == "" {
t.Fatal("provisioning returned an empty password")
}
// Re-running must converge on the same principal rather than failing or
// creating a second account.
again, secondPassword, _, err := provisionWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline)
if err != nil {
t.Fatalf("second provision: %v", err)
}
if again.Username != identity.Username || !again.SID.Equals(identity.SID) {
t.Fatalf("provisioning is not idempotent: %s then %s", identity, again)
}
if secondPassword == "" {
t.Fatal("second provision returned an empty password")
}
// Deliberately NOT logging on with secondPassword. Provisioning does not
// rotate an adopted account any more, so that value is a fresh random string
// the account does not hold; rotation happens in
// provisionWindowsSandboxPrincipalForSetup, immediately before the secret is
// written, to keep the window where no stored password authenticates as small
// as possible.
//
// The guarantee worth asserting is therefore the one the setup path makes:
// after it returns, the stored secret logs the principal on. That covers
// rotation, the secret write and the logon right in one assertion, and it is
// the thing a broken re-setup would actually break.
config := WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
WorkspaceRoots: []string{`C:\ziptest01`},
}
setupIdentity, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline)
if err != nil {
t.Fatalf("setup provision: %v", err)
}
secretPath, err := windowsSandboxSecretPath(config.SandboxHome, setupIdentity.Username)
if err != nil {
t.Fatalf("secret path: %v", err)
}
storedPassword, err := readWindowsSandboxSecret(secretPath)
if err != nil {
t.Fatalf("read stored secret: %v", err)
}
token, err := logonWindowsSandboxPrincipal(setupIdentity.Username, storedPassword)
if err != nil {
t.Fatalf("logon with the secret the setup path stored: %v", err)
}
_ = token.Close()
t.Cleanup(func() {
_ = revokeWindowsSandboxLogonRights(setupIdentity.SID)
_ = removeWindowsSandboxIdentity(setupIdentity.Username, "ziptest01")
})
// Lookup must find what provisioning created.
found, err := lookupWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline)
if err != nil {
t.Fatalf("lookup after provision: %v", err)
}
if !found.SID.Equals(identity.SID) {
t.Fatalf("lookup returned %s, want %s", found, identity)
}
}
// The other half of the privileged chain: granting logon rights and actually
// minting a token. Provisioning proves the account exists; this proves it is
// USABLE, which is the part the runner depends on.
//
// The batch logon doubles as the assertion that LsaAddAccountRights worked. A
// LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED (1385)
// unless SeBatchLogonRight is actually held, so a token coming back is proof the
// grant landed rather than merely that the call returned success.
//
// Creates a real local account and removes it again, so it is gated the same way
// as the provisioning round-trip.
func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) {
if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" {
t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)")
}
if !windowsProcessIsElevated() {
t.Skip("granting logon rights requires an elevated process")
}
const key = "ziplogon01"
// A leftover account from an interrupted run would keep its old password,
// which the freshly generated one will not match, so start from a clean slate.
_ = removeWindowsSandboxIdentity(windowsSandboxUserName(key, windowsSandboxRoleOffline), key)
identity, password, _, err := provisionWindowsSandboxIdentity(key, windowsSandboxRoleOffline)
if err != nil {
t.Fatalf("provision: %v", err)
}
t.Cleanup(func() {
// Rights first, then the account: the reverse order strands an LSA entry
// keyed to a SID that no longer resolves.
if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil {
t.Errorf("cleanup: revoke logon rights: %v", err)
}
if err := removeWindowsSandboxIdentity(identity.Username, key); err != nil && !errors.Is(err, errWindowsSandboxForeignAccountRetained) {
t.Errorf("cleanup: remove principal: %v", err)
}
})
// Exercises LsaAddAccountRights, including the LSA_UNICODE_STRING byte-length
// handling that nothing else has run.
if err := grantWindowsSandboxLogonRights(identity.SID); err != nil {
t.Fatalf("grant logon rights: %v", err)
}
// Idempotent: setup re-runs must not fail on rights the account already holds.
if err := grantWindowsSandboxLogonRights(identity.SID); err != nil {
t.Fatalf("granting logon rights twice must succeed: %v", err)
}
token, err := logonWindowsSandboxPrincipal(identity.Username, password)
if err != nil {
t.Fatalf("logon as principal: %v", err)
}
defer token.Close()
// The token must BE the principal. If this came back as the caller, the whole
// identity boundary would be an illusion and reads would still run as the user.
user, err := token.GetTokenUser()
if err != nil {
t.Fatalf("token user: %v", err)
}
if !user.User.Sid.Equals(identity.SID) {
t.Fatalf("token belongs to %s, want the principal %s", user.User.Sid, identity.SID)
}
t.Logf("minted a token for %s", identity)
}
// A workspace with no provisioned principal must report the actionable
// "run setup" error rather than a raw lookup failure, so the command path can
// fall back instead of surfacing a Win32 code.
func TestLookupWindowsSandboxIdentityUnprovisioned(t *testing.T) {
_, err := lookupWindowsSandboxIdentity("nosuchworkspacekey9z", windowsSandboxRoleOffline)
if err == nil {
t.Skip("a principal for this key unexpectedly exists on this machine")
}
if err != errWindowsSandboxIdentityUnavailable {
t.Fatalf("error = %v, want errWindowsSandboxIdentityUnavailable", err)
}
}
// A name that resolves to something other than a user account is a conflict,
// not an absent principal, and must not be reported as "setup has not run": the
// command path treats that sentinel as permission to fall back silently, so
// collapsing the two would hide a squatted account behind a quiet downgrade to
// the restricted token.
//
// Every machine already has well-known non-user names to test against, so this
// needs no privilege and no provisioning.
func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) {
// Groups that exist on any Windows install. Whichever resolves first is
// enough; localized machines may not carry the English name.
for _, group := range []string{"Administrators", "Users", "Guests"} {
sid, _, accountType, err := windows.LookupSID("", group)
if err != nil || sid == nil {
continue
}
if accountType == windows.SidTypeUser {
continue
}
resolveErr := func() error {
_, err := resolveWindowsSandboxSID(group)
return err
}()
if resolveErr == nil {
t.Fatalf("resolveWindowsSandboxSID(%q) accepted a non-user account (type %d)", group, accountType)
}
// The classification is the part that matters: the sentinel is what
// licenses the command path to fall back silently, so this refusal must
// survive it rather than be folded into it.
if classified := classifyWindowsSandboxLookupError(resolveErr); errors.Is(classified, errWindowsSandboxIdentityUnavailable) {
t.Fatalf("non-user account %q classified as unprovisioned, which would silently downgrade to the restricted token: %v", group, classified)
}
return
}
t.Skip("no well-known non-user account resolved on this machine")
}
// The account name is derived from a workspace hash, not discovered, so it can
// be occupied by a local account that has nothing to do with Zero. Adopting one
// means resetting a stranger's password, so provisioning has to prove ownership
// first and refuse otherwise.
//
// Driven against real accounts every Windows install carries, which are
// definitively not ours. Unprivileged: it only has to establish that they are
// not classified as managed, so nothing is ever created or modified.
func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) {
checked := 0
for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} {
managed, err := windowsSandboxUserIsManaged(name, "workspacekey")
if err != nil {
// Localized or disabled installs may not carry every one of these.
continue
}
checked++
if managed {
t.Fatalf("%q classified as a Zero sandbox principal; provisioning would reset its password", name)
}
}
if checked == 0 {
t.Skip("no well-known local account could be queried on this machine")
}
// An absent account must answer false rather than error, since provisioning
// asks this question about names that usually do not exist yet.
managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct", "workspacekey")
if err != nil {
t.Fatalf("querying a missing account: %v", err)
}
if managed {
t.Fatal("a missing account was classified as managed")
}
}
// The refusal has to be a typed, recognisable collision rather than a generic
// failure, so setup can say what is wrong instead of reporting a Win32 status.
func TestWindowsSandboxNameCollisionIsTyped(t *testing.T) {
wrapped := fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, "zero-sbx-dexample")
if !errors.Is(wrapped, errWindowsSandboxNameCollision) {
t.Fatal("collision error does not match its sentinel")
}
if !strings.Contains(wrapped.Error(), "not created by Zero") {
t.Fatalf("collision message = %q, want it to say the account is not ours", wrapped.Error())
}
}
// The two roles must never collide onto one account. If they did, the online
// principal would be whatever the offline one is, which means either an approved
// network command silently has no network, or an ordinary command silently has
// one. The second is the dangerous direction.
func TestWindowsSandboxUserNameSeparatesRoles(t *testing.T) {
const key = "abc123"
offline := windowsSandboxUserName(key, windowsSandboxRoleOffline)
online := windowsSandboxUserName(key, windowsSandboxRoleOnline)
if offline == online {
t.Fatalf("both roles produced %q", offline)
}
// Still stable per role, or setup would create a new account every run.
if again := windowsSandboxUserName(key, windowsSandboxRoleOnline); again != online {
t.Fatalf("online name is not stable: %q then %q", online, again)
}
// The role tag has to survive truncation. A very long key is exactly when the
// name is cut to the 20-character limit, and losing the tag there is what
// would collide the roles on precisely the workspaces most likely to hit it.
longKey := strings.Repeat("f", 200)
longOffline := windowsSandboxUserName(longKey, windowsSandboxRoleOffline)
longOnline := windowsSandboxUserName(longKey, windowsSandboxRoleOnline)
if longOffline == longOnline {
t.Fatalf("truncation collided the roles onto %q", longOffline)
}
for _, name := range []string{longOffline, longOnline} {
if len(name) > windowsSandboxUserNameMax {
t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax)
}
}
// Different workspaces still get different accounts within a role.
if other := windowsSandboxUserName("def456", windowsSandboxRoleOffline); other == offline {
t.Fatalf("two workspaces produced the same offline account %q", other)
}
}
@@ -1,90 +0,0 @@
//go:build windows
package sandbox
import (
"testing"
"golang.org/x/sys/windows"
)
// THE UPGRADE HAS TO BE OBSERVABLE, or it is not really there.
//
// windowsSandboxUserIsManaged accepts the old bare account comment and promises
// the workspace key gets stamped on so the bare form can eventually be retired.
// The probe for the legacy comment was seamed and the upgrade itself was not, so
// nothing in the suite could tell whether the call ran: deleting it left
// everything green. This repository has already had a fix silently reverted by a
// later change, which is the failure this pins.
func TestAdoptingALegacyAccountStampsTheWorkspaceKey(t *testing.T) {
stub := func(t *testing.T, legacy bool) *string {
t.Helper()
var upgraded string
prevGroup := ensureWindowsSandboxGroupFn
prevOffline := ensureWindowsSandboxOfflineGroupFn
prevEnsure := ensureWindowsSandboxUserFn
prevManaged := windowsSandboxUserIsManagedFn
prevLegacy := windowsSandboxUserHasLegacyCommentFn
prevPrivileged := windowsSandboxUserIsPrivilegedFn
prevUpgrade := upgradeWindowsSandboxUserCommentFn
prevReset := resetWindowsSandboxUserPasswordFn
prevGroupAdd := addWindowsSandboxUserToGroupFn
prevOfflineAdd := addWindowsSandboxUserToOfflineGroupFn
prevSID := resolveWindowsSandboxSIDFn
t.Cleanup(func() {
ensureWindowsSandboxGroupFn = prevGroup
ensureWindowsSandboxOfflineGroupFn = prevOffline
ensureWindowsSandboxUserFn = prevEnsure
windowsSandboxUserIsManagedFn = prevManaged
windowsSandboxUserHasLegacyCommentFn = prevLegacy
windowsSandboxUserIsPrivilegedFn = prevPrivileged
upgradeWindowsSandboxUserCommentFn = prevUpgrade
resetWindowsSandboxUserPasswordFn = prevReset
addWindowsSandboxUserToGroupFn = prevGroupAdd
addWindowsSandboxUserToOfflineGroupFn = prevOfflineAdd
resolveWindowsSandboxSIDFn = prevSID
})
ensureWindowsSandboxGroupFn = func() error { return nil }
ensureWindowsSandboxOfflineGroupFn = func() error { return nil }
// existed = true: the adoption path, which is the only one that upgrades.
ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return true, nil }
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
windowsSandboxUserHasLegacyCommentFn = func(string) (bool, error) { return legacy, nil }
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
upgradeWindowsSandboxUserCommentFn = func(_ string, workspaceKey string) error {
upgraded = workspaceKey
return nil
}
resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil }
addWindowsSandboxUserToGroupFn = func(string) error { return nil }
addWindowsSandboxUserToOfflineGroupFn = func(string) error { return nil }
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) {
return windows.StringToSid("S-1-5-32-546")
}
return &upgraded
}
t.Run("a legacy comment is upgraded", func(t *testing.T) {
upgraded := stub(t, true)
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
t.Fatalf("provisionWindowsSandboxIdentity: %v", err)
}
if *upgraded != "workspacekey" {
t.Errorf("the workspace key was never stamped onto the adopted legacy account (got %q); windowsSandboxUserIsManaged goes on accepting the bare comment forever", *upgraded)
}
})
// And an account that already carries the key is left alone, or the
// assertion above would be satisfied by an unconditional rewrite.
t.Run("a current comment is left alone", func(t *testing.T) {
upgraded := stub(t, false)
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
t.Fatalf("provisionWindowsSandboxIdentity: %v", err)
}
if *upgraded != "" {
t.Errorf("an account that already carried the workspace key was rewritten anyway: %q", *upgraded)
}
})
}
@@ -1,108 +0,0 @@
package sandbox
import (
"errors"
"sort"
"strings"
"testing"
)
// The predecessor of this branch provisioned ONE account per workspace, named
// "zero-sbx-<key>" with no role tag. Splitting the roles changed both names, so
// on an upgraded machine neither role resolves to the account that is actually
// installed. Every other path on this branch then reports the principal as never
// provisioned, and the untagged account keeps its secret, its logon rights, its
// ACEs and its ledger with nothing left that looks for them.
//
// windowsSandboxRoleLegacy exists to reproduce that name exactly so retirement
// can still find it. If its tag ever stops being empty, this stops matching what
// the predecessor wrote and the residue becomes unreachable again.
func TestLegacyRoleReproducesTheUntaggedPredecessorName(t *testing.T) {
// Short enough that the 20-character account-name limit does not truncate,
// so the comparison is against the spelling itself rather than the cap.
const key = "abc123"
legacy := windowsSandboxUserName(key, windowsSandboxRoleLegacy)
if want := windowsSandboxUserPrefix + key; legacy != want {
t.Fatalf("legacy account name = %q, want the pre-split spelling %q", legacy, want)
}
// And the cap still applies to it exactly as it did before the split, since
// the predecessor truncated the same way.
long := windowsSandboxUserName("averylongworkspacekeyindeed", windowsSandboxRoleLegacy)
if len(long) > windowsSandboxUserNameMax {
t.Errorf("legacy name %q is %d characters, over the %d-character limit", long, len(long), windowsSandboxUserNameMax)
}
if tag := windowsSandboxRoleLegacy.roleTag(); tag != "" {
t.Errorf("legacy role tag = %q, want empty; a tag here would stop matching the installed account", tag)
}
// It must not collide with either live role, or retiring the predecessor
// would delete an account this branch is still using.
offline := windowsSandboxUserName(key, windowsSandboxRoleOffline)
online := windowsSandboxUserName(key, windowsSandboxRoleOnline)
for _, live := range []string{offline, online} {
if strings.EqualFold(legacy, live) {
t.Errorf("legacy name %q collides with a live role name %q", legacy, live)
}
}
if strings.EqualFold(offline, online) {
t.Fatalf("the two live roles derived the same account name %q", offline)
}
}
// THE LEGACY ROLE IS RETIRED, NEVER PROVISIONED. It derives the untagged name
// on purpose, so provisioning it would create the very account this branch
// replaced, on every setup, forever. Adding it to the retirement list and the
// provisioning list are one character apart in the source and the difference is
// invisible at a glance, so it is asserted rather than left to review.
func TestSetupNeverProvisionsTheLegacyPrincipal(t *testing.T) {
previous := provisionWindowsSandboxPrincipalForSetupFn
t.Cleanup(func() { provisionWindowsSandboxPrincipalForSetupFn = previous })
var provisioned []windowsSandboxRole
provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) {
provisioned = append(provisioned, role)
return windowsSandboxIdentity{}, false, errStopProvisioningForTest
}
// The error stops setup at the first role; what matters is which roles it
// was willing to ask for, and legacy must never be among them.
_, _ = setupWindowsSandboxPrincipal(WindowsSandboxCommandConfig{SandboxHome: t.TempDir()})
for _, role := range provisioned {
if role == windowsSandboxRoleLegacy {
t.Fatalf("setup tried to provision the legacy role, which would recreate the pre-split account: %v", provisioned)
}
}
}
var errStopProvisioningForTest = errors.New("stop provisioning for test")
// Opting out or re-running setup has to retire the predecessor as well as both
// live roles. Retiring only the two this branch knows about is what leaves a
// fully provisioned account on a machine whose marker says it has none.
func TestSetupRetirementCoversTheLegacyPrincipal(t *testing.T) {
previous := removeWindowsSandboxPrincipalForSetupFn
t.Cleanup(func() { removeWindowsSandboxPrincipalForSetupFn = previous })
var retired []string
removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error {
retired = append(retired, string(role))
return nil
}
if err := removeWindowsSandboxPrincipalsForSetup(WindowsSandboxCommandConfig{SandboxHome: t.TempDir()}); err != nil {
t.Fatalf("removeWindowsSandboxPrincipalsForSetup: %v", err)
}
sort.Strings(retired)
want := []string{"legacy", "offline", "online"}
if len(retired) != len(want) {
t.Fatalf("retired %v, want all of %v; a role left out keeps its account and everything it owns", retired, want)
}
for index := range want {
if retired[index] != want[index] {
t.Fatalf("retired %v, want %v", retired, want)
}
}
}
+1 -176
View File
@@ -62,190 +62,15 @@ func BuildWindowsNetworkInfraPlan(config WindowsSandboxCommandConfig) (WindowsNe
if err != nil {
return WindowsNetworkPlan{}, err
}
// The offline-marker SID stays first: the setup marker records
// IdentitySIDs[0] as the offline filter identity.
identitySIDs := []string{offlineSID}
// A sandbox principal cannot carry the offline-marker SID, because LogonUser
// builds a token from an account's real group memberships and the marker is a
// synthetic capability SID. So the same filters additionally match a real
// local group that network-denied principals belong to.
//
// Gated on THIS home's opt-in, not merely on the group existing.
//
// The group is machine-global, so keying off its existence meant the first
// workspace to opt in changed the computed plan for every OTHER sandbox home
// on the machine. The plan is hashed into the setup marker and compared on
// every command, so those homes started failing every command with "setup is
// out of date" until each was re-run from an elevated terminal, having never
// opted into anything. Existing markers have to keep validating.
//
// Reading the opt-in instead keeps an opted-out home computing exactly the
// plan it computed before any of this existed. An opted-in home records the
// group in its own marker, and setup and the command path both derive the
// flag from the same environment, so they agree. Opting in AFTER setup does
// invalidate that home's marker, which is correct: it has no principals yet
// and setup is genuinely required.
if windowsSandboxIdentityEnabled(config.Env) && resolveWindowsSandboxOfflineGroupSIDHook != nil {
groupSID, err := resolveWindowsSandboxOfflineGroupSIDHook()
if err != nil {
return WindowsNetworkPlan{}, err
}
if trimmed := strings.TrimSpace(groupSID); trimmed != "" {
identitySIDs = append(identitySIDs, trimmed)
}
}
return WindowsNetworkPlan{
Mode: NetworkDeny,
ProviderKey: windowsWFPProviderKey,
SubLayerKey: windowsWFPSubLayerKey,
IdentitySIDs: identitySIDs,
IdentitySIDs: []string{offlineSID},
Filters: windowsDenyWFPFilterSpecs(),
}, nil
}
// WindowsNetworkPlanForApply returns the plan to INSTALL, which is not always
// the plan a home fingerprints.
//
// The filters are machine-global and every setup installs them by deleting and
// recreating the fixed set. So an opted-OUT setup for workspace B was replacing
// filters that workspace A's opted-in setup had installed, dropping the offline
// group SID as it went. A's offline principal still passed the runtime
// membership check and A's marker still validated, but no filter matched its
// token any more, so a NetworkDeny command in A quietly gained egress. Nothing
// on either side reported anything: B did exactly what it was asked to.
//
// The gate in BuildWindowsNetworkInfraPlan stays as it is, and this is
// deliberately a SEPARATE function rather than a change to it. That gate exists
// because the plan is hashed into each home's setup marker: keying it on the
// group's existence made the first workspace to opt in invalidate every other
// home's marker on the machine, and those homes then failed every command with
// "setup is out of date" having opted into nothing. That has to keep working.
//
// The two questions are simply different. What a home RECORDS is about that
// home's own configuration; what setup INSTALLS is about the machine, where the
// group either exists or does not. Answering both from one plan is what forced a
// choice between stale markers and a silent hole.
//
// The group not existing is the ordinary opted-out machine, and it adds nothing.
//
// A resolution FAILURE is a different answer and is fatal, because the filters
// are replaced wholesale. The earlier reasoning here was that refusing to
// install would trade a partial denial for no denial at all, and that is wrong:
// the alternative to installing is leaving the filters that are already there
// alone. Returning a marker-only plan does not decline to add coverage, it
// actively REMOVES coverage another workspace is relying on. An opted-out home
// skips assertWindowsNetworkPlanCoversOfflineGroup entirely (it is gated on
// provisioned), so this is the only thing between a transient lookup error and
// workspace A silently regaining egress under NetworkDeny.
//
// "Absent" and "could not be read" have to be different answers, which is the
// same distinction the marker/apply split above is built on.
func WindowsNetworkPlanForApply(plan WindowsNetworkPlan, resolveOfflineGroupSID func() (string, error)) (WindowsNetworkPlan, error) {
if resolveOfflineGroupSID == nil {
return plan, nil
}
groupSID, err := resolveOfflineGroupSID()
if err != nil {
return WindowsNetworkPlan{}, fmt.Errorf("resolve the sandbox offline group before replacing the machine network filters: %w", err)
}
trimmed := strings.TrimSpace(groupSID)
if trimmed == "" {
return plan, nil
}
for _, existing := range plan.IdentitySIDs {
if strings.EqualFold(strings.TrimSpace(existing), trimmed) {
return plan, nil
}
}
// Copied rather than appended in place: the caller's plan is what gets
// fingerprinted, and growing its backing array would risk changing the
// recorded plan as a side effect of installing one.
augmented := plan
augmented.IdentitySIDs = append(append([]string{}, plan.IdentitySIDs...), trimmed)
return augmented, nil
}
// WindowsNetworkPlanCoversPrincipals reports whether a plan's block filters name
// the offline group, which is the only thing that makes them apply to a sandbox
// principal.
//
// This exists to be asserted, not consulted. The plan must be built AFTER
// provisioning, because provisioning is what creates the group it resolves; a
// plan built first names only the offline marker and leaves every offline
// principal with an open network while setup reports success. That is a control
// that enforces nothing while claiming to work, and the ordering which prevents
// it is invisible at the call site, so a later refactor can undo it silently.
func WindowsNetworkPlanCoversPrincipals(plan WindowsNetworkPlan, offlineGroupSID string) bool {
offlineGroupSID = strings.TrimSpace(offlineGroupSID)
if offlineGroupSID == "" {
// No group provisioned on this host, so there is no principal for the
// filters to miss and nothing to assert.
return true
}
for _, sid := range plan.IdentitySIDs {
if strings.EqualFold(strings.TrimSpace(sid), offlineGroupSID) {
return true
}
}
return false
}
// assertWindowsNetworkPlanCoversOfflineGroup is the post-provisioning check that
// setup runs before it installs filters and reports success.
//
// It fails closed on every answer that is not a definite yes, because the thing
// it guards is invisible when it goes wrong: filters that do not name the
// offline group leave every offline principal with an open network on a machine
// whose setup marker says it is protected. Nobody sees an error, and the sandbox
// looks correctly installed.
//
// Two answers other than "the plan omits the group" also mean the check did not
// happen and must not be treated as a pass:
//
// - The lookup failed. Whether the group is covered is then unknown, and the
// Win32 reason is worth surfacing: an operator seeing "access is denied"
// knows to re-run elevated.
// - The lookup succeeded and found nothing. ("", nil) means the group does not
// exist, which is the ordinary state BEFORE provisioning and an impossible
// one after it, so here it means the group vanished or was never created.
// WindowsNetworkPlanCoversPrincipals answers true for an empty SID, correctly
// for the pre-provisioning callers that ask it, so the emptiness has to be
// rejected here rather than delegated to it.
//
// provisioned says whether principals were actually provisioned on this run.
// When they were not there is no offline group, no principal carrying it, and
// nothing for the filters to miss, so there is nothing to assert. Running the
// check anyway turns the ordinary opt-out setup into a hard failure, because a
// machine with no group resolves to ("", nil) and the empty-SID rejection below
// is correct only after provisioning.
func assertWindowsNetworkPlanCoversOfflineGroup(plan WindowsNetworkPlan, resolve func() (string, error), provisioned bool) error {
if !provisioned {
return nil
}
if resolve == nil {
return errors.New("sandbox offline group resolver is not wired up, so filter coverage cannot be verified")
}
groupSID, err := resolve()
if err != nil {
return fmt.Errorf("resolve the sandbox offline group after provisioning, so filter coverage cannot be verified: %w", err)
}
if strings.TrimSpace(groupSID) == "" {
return errors.New("the sandbox offline group does not exist after provisioning, so the block filters would not apply to any sandbox principal")
}
if !WindowsNetworkPlanCoversPrincipals(plan, groupSID) {
return errors.New("network block filters do not name the sandbox offline group, so they would not apply to any sandbox principal; the network plan must be built after principals are provisioned")
}
return nil
}
// resolveWindowsSandboxOfflineGroupSIDHook resolves the local group that
// network-denied principals belong to. It is wired up on Windows only, so this
// file stays free of Win32 calls and the plan on other platforms is unchanged.
//
// Returning ("", nil) means the group does not exist yet, which is the state
// before principals have ever been provisioned.
var resolveWindowsSandboxOfflineGroupSIDHook func() (string, error)
// WindowsNetworkInfraHash fingerprints the provisioned (mode-independent) network
// infrastructure so the setup marker validates against the same setup for BOTH
// command modes. It never folds in the per-command network mode.
@@ -1,129 +0,0 @@
package sandbox
import (
"errors"
"strings"
"testing"
)
// The post-provisioning coverage assertion is the last thing standing between a
// mis-ordered plan and a machine that reports a successful setup while every
// offline principal has an open network. It has to fail closed on every way of
// not knowing the answer, not just on a definite negative.
func TestAssertWindowsNetworkPlanCoversOfflineGroupFailsClosed(t *testing.T) {
const groupSID = "S-1-5-32-9999"
covering := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker", groupSID}}
notCovering := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}}
lookupFailed := errors.New("look up zero-sandbox-offline: access is denied")
tests := []struct {
name string
plan WindowsNetworkPlan
resolve func() (string, error)
wantErr bool
wantText string
}{
{
name: "plan names the group",
plan: covering,
resolve: func() (string, error) { return groupSID, nil },
},
{
// The finding anandh8x raised: a failed lookup skipped the assertion
// entirely and setup carried on to write a success marker.
name: "lookup fails",
plan: covering,
resolve: func() (string, error) { return "", lookupFailed },
wantErr: true,
wantText: "access is denied",
},
{
// Empty-and-no-error means "the group does not exist", which is a
// legitimate answer BEFORE provisioning and an impossible one after it.
// WindowsNetworkPlanCoversPrincipals answers true for an empty SID, so
// without an explicit check the assertion passes vacuously.
name: "group missing after provisioning",
plan: covering,
resolve: func() (string, error) { return "", nil },
wantErr: true,
wantText: "does not exist",
},
{
name: "whitespace-only SID",
plan: covering,
resolve: func() (string, error) { return " ", nil },
wantErr: true,
wantText: "does not exist",
},
{
name: "plan omits the group",
plan: notCovering,
resolve: func() (string, error) { return groupSID, nil },
wantErr: true,
wantText: "do not name the sandbox offline group",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := assertWindowsNetworkPlanCoversOfflineGroup(test.plan, test.resolve, true)
if test.wantErr && err == nil {
t.Fatalf("assertWindowsNetworkPlanCoversOfflineGroup() = nil, want an error so setup rolls back")
}
if !test.wantErr {
if err != nil {
t.Fatalf("assertWindowsNetworkPlanCoversOfflineGroup() = %v, want nil", err)
}
return
}
if !strings.Contains(err.Error(), test.wantText) {
t.Fatalf("error = %q, want it to mention %q so the operator can tell the cases apart", err, test.wantText)
}
})
}
// The lookup failure has to stay unwrapped-comparable: setup logs it and an
// operator needs the underlying Win32 reason, not a flattened string.
err := assertWindowsNetworkPlanCoversOfflineGroup(covering, func() (string, error) { return "", lookupFailed }, true)
if !errors.Is(err, lookupFailed) {
t.Fatalf("errors.Is(err, lookupFailed) = false; the cause was dropped: %v", err)
}
}
// The assert only means something AFTER principals were provisioned. The offline
// group is created inside provisionWindowsSandboxIdentity, which runs only under
// the ZERO_WINDOWS_SANDBOX_IDENTITY opt-in, so on a default machine there is no
// group, no principal, and nothing for the filters to miss.
//
// Rejecting an empty SID is right once provisioning ran (that is the vacuous
// pass anandh8x found) and wrong before it: it turned the ordinary opt-out setup
// into a hard failure, which is the regression jatmn caught on #812.
func TestAssertWindowsNetworkPlanCoversOfflineGroupSkipsWhenNothingWasProvisioned(t *testing.T) {
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}}
// The default machine: no group exists, so the resolver reports ("", nil).
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { return "", nil }, false); err != nil {
t.Fatalf("opt-out setup was refused: %v", err)
}
// Same state WITH provisioning claimed: now the missing group is a real fault.
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { return "", nil }, true); err == nil {
t.Fatal("a missing group after provisioning must still fail closed")
}
// Not provisioned must not become a licence to skip a lookup failure either:
// the resolver is never consulted, so a broken lookup cannot matter here.
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) {
t.Fatal("the resolver must not be consulted when nothing was provisioned")
return "", nil
}, false); err != nil {
t.Fatalf("opt-out setup was refused: %v", err)
}
}
// A nil resolver is a wiring mistake, and guessing that coverage is fine is the
// one thing it must not do.
func TestAssertWindowsNetworkPlanCoversOfflineGroupRejectsNilResolver(t *testing.T) {
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-32-9999"}}
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, nil, true); err == nil {
t.Fatal("assertWindowsNetworkPlanCoversOfflineGroup(nil resolver) = nil, want an error")
}
}
@@ -1,163 +0,0 @@
package sandbox
import (
"errors"
"strings"
"testing"
)
// THE BLOCK FILTERS ARE MACHINE-GLOBAL, SO ONE WORKSPACE'S SETUP DECIDES ANOTHER
// WORKSPACE'S NETWORK.
//
// Every setup installs them by deleting and recreating one fixed set. The plan
// each home builds names the offline group only when THAT home opted in, which
// is right for the marker it fingerprints and wrong for the filters it installs:
// an ordinary opted-out setup for a second workspace replaced the filters
// without the group SID, and the first workspace's offline principal — still in
// the group, still passing the runtime membership check, still holding a valid
// marker — was no longer matched by any filter. A NetworkDeny command there
// gained egress, silently, because the second setup did exactly what it was
// asked to do.
//
// The plan a home installs and the plan it records are therefore different
// questions, and these pin both.
const testOfflineGroupSID = "S-1-5-32-9990"
func offlineGroupResolver(sid string) func() (string, error) {
return func() (string, error) { return sid, nil }
}
// The regression itself: an opted-OUT home must still install filters covering
// the managed offline group, because an opted-IN home's principals depend on it.
func TestOptedOutSetupKeepsOfflineGroupCoverageForOtherWorkspaces(t *testing.T) {
optedOut, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
// No opt-in in the environment: this is the ordinary workspace.
Env: map[string]string{},
})
if err != nil {
t.Fatalf("BuildWindowsNetworkInfraPlan: %v", err)
}
// What this home RECORDS must not change, or every opted-out marker on the
// machine goes stale the moment another workspace opts in.
if WindowsNetworkPlanCoversPrincipals(optedOut, testOfflineGroupSID) {
t.Fatal("the recorded plan named the offline group; that is what invalidated other homes' markers")
}
// What this home INSTALLS must cover the group, since the group exists.
applied, err := WindowsNetworkPlanForApply(optedOut, offlineGroupResolver(testOfflineGroupSID))
if err != nil {
t.Fatalf("WindowsNetworkPlanForApply: %v", err)
}
if !WindowsNetworkPlanCoversPrincipals(applied, testOfflineGroupSID) {
t.Errorf("an opted-out setup would install filters that do not match the offline group, so another workspace's NetworkDeny commands gain egress: %v", applied.IdentitySIDs)
}
// The offline-marker SID has to survive alongside it: the restricted-token
// tier is matched by that one, and it is the whole denial on machines with no
// principals at all.
if len(applied.IdentitySIDs) == 0 || strings.TrimSpace(applied.IdentitySIDs[0]) == "" {
t.Fatalf("the offline-marker SID was lost from the applied plan: %v", applied.IdentitySIDs)
}
if applied.IdentitySIDs[0] != optedOut.IdentitySIDs[0] {
t.Errorf("the marker SID must stay first, since the setup marker records IdentitySIDs[0]: %v", applied.IdentitySIDs)
}
}
// Augmenting must not mutate the plan the caller fingerprints. Appending in
// place would let installing a plan change the plan that was recorded.
func TestPlanForApplyDoesNotMutateTheRecordedPlan(t *testing.T) {
recorded, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
Env: map[string]string{},
})
if err != nil {
t.Fatalf("BuildWindowsNetworkInfraPlan: %v", err)
}
before := append([]string{}, recorded.IdentitySIDs...)
if _, err := WindowsNetworkPlanForApply(recorded, offlineGroupResolver(testOfflineGroupSID)); err != nil {
t.Fatalf("WindowsNetworkPlanForApply: %v", err)
}
if len(recorded.IdentitySIDs) != len(before) {
t.Fatalf("the recorded plan grew from %v to %v", before, recorded.IdentitySIDs)
}
for index := range before {
if recorded.IdentitySIDs[index] != before[index] {
t.Errorf("the recorded plan changed at %d: %v vs %v", index, before, recorded.IdentitySIDs)
}
}
}
// An opted-IN home already names the group, and must not name it twice: a
// duplicate identity would change the installed filter set for no reason.
func TestPlanForApplyDoesNotDuplicateAnAlreadyCoveredGroup(t *testing.T) {
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker", testOfflineGroupSID}}
applied, err := WindowsNetworkPlanForApply(plan, offlineGroupResolver(strings.ToLower(testOfflineGroupSID)))
if err != nil {
t.Fatalf("WindowsNetworkPlanForApply: %v", err)
}
if len(applied.IdentitySIDs) != 2 {
t.Errorf("the group was added again despite already being covered: %v", applied.IdentitySIDs)
}
}
// On an ordinary machine with no principals the group does not exist, and the
// plan must be left exactly as it was.
func TestPlanForApplyLeavesAMachineWithoutTheGroupAlone(t *testing.T) {
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}}
for _, testCase := range []struct {
name string
resolve func() (string, error)
}{
{name: "no resolver", resolve: nil},
{name: "group absent", resolve: func() (string, error) { return "", nil }},
{name: "blank SID", resolve: func() (string, error) { return " ", nil }},
} {
t.Run(testCase.name, func(t *testing.T) {
applied, err := WindowsNetworkPlanForApply(plan, testCase.resolve)
if err != nil {
t.Fatalf("a machine with no offline group is not a failure: %v", err)
}
if len(applied.IdentitySIDs) != 1 || applied.IdentitySIDs[0] != "S-1-5-21-marker" {
t.Errorf("plan changed on a machine with no offline group: %v", applied.IdentitySIDs)
}
})
}
}
// A LOOKUP FAILURE IS NOT AN ABSENT GROUP. The filters are machine-global and
// are replaced wholesale, so returning a marker-only plan here does not decline
// to add coverage, it removes coverage another workspace is relying on.
//
// This is reachable only from an opted-OUT home, because
// assertWindowsNetworkPlanCoversOfflineGroup is gated on provisioned and never
// runs for one. Workspace A opts in, the group SID lands in the global filters;
// workspace B is opted out and its lookup fails transiently; B installs a plan
// without the group and A's NetworkDeny commands silently regain egress while
// A's marker and its direct membership check both still pass.
func TestPlanForApplyRefusesToReplaceFiltersWhenTheGroupCannotBeRead(t *testing.T) {
// Opted-out home B: the coverage assert is skipped for it, so nothing else
// stands between the failure and the wholesale replace.
if err := assertWindowsNetworkPlanCoversOfflineGroup(
WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-B-marker"}},
func() (string, error) { return "", errors.New("group lookup failed") },
false,
); err != nil {
t.Fatalf("the coverage assert is meant to be skipped for an opted-out home: %v", err)
}
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-B-marker"}}
applied, err := WindowsNetworkPlanForApply(plan, func() (string, error) {
return "", errors.New("group lookup failed")
})
if err == nil {
t.Fatalf("a failed group lookup installed %v, stripping the offline group from the machine's filters", applied.IdentitySIDs)
}
if !strings.Contains(err.Error(), "group lookup failed") {
t.Errorf("the underlying failure must be reported so the operator can act on it: %v", err)
}
}
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more