mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f30cd860 | ||
|
|
a0b036a3c7 | ||
|
|
164150633a |
No files matched your search
@@ -15,6 +15,7 @@
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
@@ -87,9 +88,18 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
|
||||
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
@@ -85,9 +86,18 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
|
||||
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ Discovery roots (earlier wins on name collisions):
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
@@ -140,6 +142,15 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
|
||||
|
||||
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
|
||||
|
||||
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
|
||||
|
||||
```text
|
||||
pdf/
|
||||
SKILL.md
|
||||
scripts/fill.py
|
||||
references/form-fields.md
|
||||
```
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -92,13 +92,25 @@ func runSkillAdd(args []string, dir string, stdout io.Writer, stderr io.Writer)
|
||||
lines := []string{}
|
||||
if result.Updated {
|
||||
lines = append(lines, fmt.Sprintf("Updated skill %q.", result.Name))
|
||||
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
|
||||
switch {
|
||||
case result.PreviousHash != result.Hash:
|
||||
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
|
||||
case result.PreviousTreeHash == "":
|
||||
// The install this replaced predates tree hashes and copied SKILL.md
|
||||
// alone, so the bundled files are new rather than changed.
|
||||
lines = append(lines, " SKILL.md is unchanged; the files bundled with it are now installed too")
|
||||
default:
|
||||
lines = append(lines, " SKILL.md is unchanged; the files bundled with it changed")
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("Installed skill %q.", result.Name))
|
||||
lines = append(lines, " hash: "+result.Hash)
|
||||
}
|
||||
lines = append(lines, " source: "+result.Source)
|
||||
lines = append(lines, " path: "+result.Path)
|
||||
if len(result.SkippedLinks) > 0 {
|
||||
lines = append(lines, " not copied (symlinks): "+strings.Join(result.SkippedLinks, ", "))
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/skills"
|
||||
)
|
||||
|
||||
func writeSourceSkillDir(t *testing.T, dir string, content string) string {
|
||||
@@ -391,3 +393,133 @@ func TestRunDistributionCommandsDoNotLaunchTUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reinstall that only changed a bundled script must still read as an update
|
||||
// (#584): the SKILL.md hash is the same, so "hash: X -> X" would say nothing.
|
||||
func TestRunSkillAddReportsAnUpdateToBundledFilesOnly(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
|
||||
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
|
||||
script := filepath.Join(src, "scripts", "fill.py")
|
||||
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('v1')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := appDeps{skillsDir: func() string { return skillsDir }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('v2')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
for _, want := range []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it changed"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An install from before tree hashes copied SKILL.md alone and recorded no tree
|
||||
// hash. Adding the skill again over that entry must not claim bundled files
|
||||
// changed: there are none, or they are installed for the first time.
|
||||
func TestRunSkillAddOverAnEntryWithoutATreeHash(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
script bool
|
||||
want []string
|
||||
notWant []string
|
||||
}{
|
||||
{
|
||||
name: "SKILL.md only",
|
||||
want: []string{`Installed skill "pdf".`},
|
||||
notWant: []string{"Updated skill", "files bundled with it"},
|
||||
},
|
||||
{
|
||||
name: "with a bundled script",
|
||||
script: true,
|
||||
want: []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it are now installed too"},
|
||||
notWant: []string{"files bundled with it changed"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
|
||||
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
|
||||
if tc.script {
|
||||
script := filepath.Join(src, "scripts", "fill.py")
|
||||
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
deps := appDeps{skillsDir: func() string { return skillsDir }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
lock, err := skills.ReadLock(skillsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := lock["pdf"]
|
||||
entry.TreeHash = ""
|
||||
lock["pdf"] = entry
|
||||
data, err := json.Marshal(lock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillsDir, skills.LockFileName), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, notWant := range tc.notWant {
|
||||
if strings.Contains(stdout.String(), notWant) {
|
||||
t.Fatalf("second add output says %q:\n%s", notWant, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillAddListsTheSymlinksItDidNotCopy(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
|
||||
"---\nname: pdf\ndescription: Fill forms.\n---\nbody\n")
|
||||
outside := filepath.Join(t.TempDir(), "notes.md")
|
||||
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(src, "notes.md")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := appDeps{skillsDir: func() string { return skillsDir }}
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "not copied (symlinks): notes.md") {
|
||||
t.Fatalf("add output does not list the skipped symlink:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
+3
-12
@@ -174,7 +174,7 @@ 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)
|
||||
}
|
||||
setupPlan, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
@@ -182,18 +182,9 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
setupArgs := append(append([]string{}, setupHelper.ArgsPrefix...), setupPlan.Args...)
|
||||
// BUILDING THE ARGS ALREADY WROTE TO DISK. Selecting the runtime root takes a
|
||||
// lease, and taking a lease creates the runtime tree and the lease file when
|
||||
// they are not there. A helper that never publishes a marker leaves that tree
|
||||
// behind, attested by nothing, so the undo belongs on this side of the process
|
||||
// boundary where the record of it lives.
|
||||
setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...)
|
||||
if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil {
|
||||
message := "Windows sandbox setup failed: " + err.Error()
|
||||
if undo := setupPlan.Rollback(); undo != nil {
|
||||
message += "\nleftover sandbox runtime state could not be removed: " + undo.Error()
|
||||
}
|
||||
return writeAppError(stderr, message, exitProvider)
|
||||
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
|
||||
}
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: "windows",
|
||||
|
||||
@@ -99,77 +99,11 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
return &result
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
// A RECORDED ROOT IS HISTORY, NOT PROOF. Doctor pins the marker's runtime
|
||||
// root so the stamp check can run without taking a lease, but a command does
|
||||
// not pin blindly: it derives the current cache and fallback candidates and
|
||||
// honours the marker only when its root is one of them. Run setup with the
|
||||
// cache at A, relocate the cache so commands derive B, and the stamped A tree
|
||||
// remains: pinning A here reported a healthy sandbox immediately before every
|
||||
// real command rejected A and failed on the out-of-date marker.
|
||||
//
|
||||
// So ask the command's own question first, through the command's own
|
||||
// function. Only a root a command would still select is used to validate
|
||||
// the marker; anything else is reported as out of date, with the remedy.
|
||||
//
|
||||
// THREE STATES, NOT TWO. The helper answers current, stale, or "I could not
|
||||
// tell", and the last one used to fall through to the pinned branch: a valid
|
||||
// old marker then made doctor report healthy while selectSandboxRuntimeRoot,
|
||||
// reached by BuildCommandPlan, propagated the same resolution error and stopped
|
||||
// every command from launching. Historical state is not a substitute for a
|
||||
// failed current selection, so an error is surfaced as its own warning with the
|
||||
// cause, and the pinned validation profile is built only once currentness has
|
||||
// actually been established.
|
||||
recorded, current, rootErr := sandbox.WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot)
|
||||
if rootErr != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root a command would select cannot be resolved (%s), so the recorded setup cannot be trusted and commands are likely to fail to launch.", backend.Name, rootErr.Error()), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "runtime-root-unresolved",
|
||||
"error": rootErr.Error(),
|
||||
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
if recorded != "" && !current {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root setup recorded (%s) is not one a command would select now, so the setup is out of date.", backend.Name, recorded), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "runtime-root-stale",
|
||||
"runtimeRoot": recorded,
|
||||
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
setupConfig := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
// The same augmentation setup and the command plan apply, so doctor
|
||||
// fingerprints what a real command fingerprints. Checking the bare profile
|
||||
// made doctor call a correctly prepared machine "out of date", which is the
|
||||
// mismatch this pairing exists to close. Safe to resolve in this process:
|
||||
// doctor runs in the operator's shell, not behind the sandbox TEMP
|
||||
// redirection that stops the runner deriving these for itself.
|
||||
//
|
||||
// The RECORDED runtime root goes on the profile as well, because that is
|
||||
// what makes the stamp check run at all. Without it profile.Runtime is nil,
|
||||
// validateWindowsSandboxRuntimeStamp returns nil early, and doctor reported
|
||||
// a healthy sandbox on a machine whose runtime tree had been evicted and
|
||||
// silently recreated without the capability ACE -- precisely the state the
|
||||
// stamp exists to catch, and the state where every sandboxed command then
|
||||
// fails with nothing explaining why.
|
||||
//
|
||||
// Read from the marker rather than selected, so doctor takes no lease and
|
||||
// creates nothing, and read ONCE: this is the root whose currentness was
|
||||
// checked above, not a second read that a rewritten marker could change. A
|
||||
// marker from an older schema records no root, which leaves this exactly as
|
||||
// it was.
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(
|
||||
sandbox.PermissionProfileWithRuntimeRoot(profile, recorded),
|
||||
[]string{workspaceRoot},
|
||||
),
|
||||
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{
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// DOCTOR HAS TO ASK THE ONE QUESTION THE MARKER CANNOT ANSWER.
|
||||
//
|
||||
// The marker hashes ACL-plan entries, which are pathnames. Whether the directory
|
||||
// those pathnames resolve to is still the tree setup provisioned is a different
|
||||
// question, and the runtime stamp is what answers it. validateWindowsSandboxRuntimeStamp
|
||||
// returns nil early when profile.Runtime is nil, which is correct for the setup
|
||||
// side and for unrestricted profiles, and was wrong here: doctor built its
|
||||
// profile with PermissionProfileFromPolicy, which never sets Runtime, so the
|
||||
// check was skipped and `zero doctor` reported a healthy sandbox on exactly the
|
||||
// state the stamp was added to detect -- an evicted runtime tree, silently
|
||||
// recreated with ordinary permissions and no capability ACE, where every
|
||||
// sandboxed command then fails with nothing explaining why.
|
||||
// doctorRuntimeCandidate returns a runtime root this workspace would really
|
||||
// select, taken from the candidate set the Windows plan folds in.
|
||||
//
|
||||
// It has to be a REAL candidate. An arbitrary path is already in the ACL plan on
|
||||
// one side and not the other, so the plan hashes diverge and validation fails
|
||||
// before the stamp is ever consulted -- which would make this test fail without
|
||||
// the fix for a reason that has nothing to do with the stamp.
|
||||
// redirectUserCache points os.UserCacheDir at test-owned storage.
|
||||
//
|
||||
// WITHOUT THIS THE TEST WRITES INTO THE DEVELOPER'S REAL CACHE. The runtime
|
||||
// candidate is derived from the process's actual user cache directory, so the
|
||||
// test was creating, recursively removing, recreating and then cleanup-removing
|
||||
// a directory under the real %LocalAppData%zerountime (or ~/.cache/zero on
|
||||
// Unix). The workspace digest made a collision with a live runtime tree
|
||||
// unlikely rather than impossible, and "unlikely" is not the standard for a
|
||||
// test that calls RemoveAll.
|
||||
//
|
||||
// All three variables, because os.UserCacheDir reads a different one per
|
||||
// platform: %LocalAppData% on Windows, $XDG_CACHE_HOME or $HOME on Unix, and
|
||||
// $HOME on macOS.
|
||||
func redirectUserCache(t *testing.T) {
|
||||
t.Helper()
|
||||
cache := t.TempDir()
|
||||
t.Setenv("LOCALAPPDATA", cache)
|
||||
t.Setenv("XDG_CACHE_HOME", cache)
|
||||
t.Setenv("HOME", cache)
|
||||
}
|
||||
|
||||
func doctorRuntimeCandidate(t *testing.T, workspace string) string {
|
||||
t.Helper()
|
||||
bare := doctorProfile(t, workspace)
|
||||
augmented := sandbox.WindowsSandboxProfileWithRuntimeRoots(bare, []string{workspace})
|
||||
existing := map[string]bool{}
|
||||
for _, root := range bare.FileSystem.WriteRoots {
|
||||
existing[root.Root] = true
|
||||
}
|
||||
for _, root := range augmented.FileSystem.WriteRoots {
|
||||
if !existing[root.Root] {
|
||||
return root.Root
|
||||
}
|
||||
}
|
||||
t.Skip("no runtime candidate is derivable in this environment")
|
||||
return ""
|
||||
}
|
||||
|
||||
func doctorProfile(t *testing.T, workspace string) sandbox.PermissionProfile {
|
||||
t.Helper()
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
return sandbox.PermissionProfileFromPolicy(workspace, doctorSandboxPolicy(config.SandboxConfig{}), scope)
|
||||
}
|
||||
|
||||
func writeDoctorSetupMarker(t *testing.T, home, workspace, runtimeRoot string) {
|
||||
t.Helper()
|
||||
profile := doctorProfile(t, workspace)
|
||||
setup := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(
|
||||
sandbox.PermissionProfileWithRuntimeRoot(profile, runtimeRoot),
|
||||
[]string{workspace},
|
||||
),
|
||||
}
|
||||
if _, err := sandbox.WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorReportsAnEvictedRuntimeTree(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
redirectUserCache(t)
|
||||
|
||||
runtimeRoot := doctorRuntimeCandidate(t, workspace)
|
||||
// Belt and braces: if the redirection above ever stops working, fail loudly
|
||||
// rather than quietly operating on the developer's real cache.
|
||||
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
|
||||
t.Fatalf("the runtime candidate %q is outside test-owned storage; this test creates and removes that path", runtimeRoot)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
|
||||
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
|
||||
|
||||
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
|
||||
|
||||
// A healthy machine first, or the eviction assertion below would be satisfied
|
||||
// by a check that warns unconditionally.
|
||||
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
|
||||
t.Fatalf("a freshly set-up machine was reported unhealthy: %s", result.Message)
|
||||
}
|
||||
|
||||
// cleanupSandboxRuntimeRoots evicts inactive roots on an age and count policy,
|
||||
// and the next run recreates the pathname with inherited permissions. The plan
|
||||
// hash never moves, so only the stamp can tell.
|
||||
if err := os.RemoveAll(runtimeRoot); err != nil {
|
||||
t.Fatalf("evict the runtime root: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("recreate the pathname the way an ordinary run would: %v", err)
|
||||
}
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
|
||||
if result == nil {
|
||||
t.Fatal("doctor reported a healthy sandbox while the provisioned runtime tree was gone; every sandboxed command on this machine would fail with nothing explaining why")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(result.Message), "setup") {
|
||||
t.Errorf("the warning does not point at setup: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// UNKNOWN IS NOT CURRENT.
|
||||
//
|
||||
// WindowsSandboxRecordedRuntimeRootIsCurrent answers three ways: the recorded
|
||||
// root is still one a command would select, it is not, or the inputs a command
|
||||
// selects from cannot be resolved at all. The third used to fall through to the
|
||||
// pinned branch, so a valid old marker and stamp made doctor report healthy
|
||||
// while selectSandboxRuntimeRoot — reached by BuildCommandPlan — propagated the
|
||||
// same resolution failure and stopped every command from launching. Doctor and
|
||||
// the command disagreed about the same machine.
|
||||
//
|
||||
// Stale and unknown are different states, so this is deliberately separate from
|
||||
// the cache-A-to-cache-B test rather than another assertion on the same boolean.
|
||||
func TestDoctorWarnsWhenTheRuntimeRootCannotBeResolved(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
redirectUserCache(t)
|
||||
|
||||
runtimeRoot := doctorRuntimeCandidate(t, workspace)
|
||||
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
|
||||
t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
|
||||
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
|
||||
|
||||
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
|
||||
|
||||
// Healthy first, so the assertion below cannot be satisfied by a check that
|
||||
// warns unconditionally.
|
||||
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
|
||||
t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message)
|
||||
}
|
||||
|
||||
// Now take away the inputs the selection is derived from, leaving the marker
|
||||
// and the provisioned tree exactly as they were. os.UserCacheDir fails with no
|
||||
// location to read, which is the same error a command would hit.
|
||||
t.Setenv("LOCALAPPDATA", "")
|
||||
t.Setenv("XDG_CACHE_HOME", "")
|
||||
t.Setenv("HOME", "")
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
|
||||
if result == nil {
|
||||
t.Fatal("doctor reported a healthy sandbox while the runtime root a command selects could not be resolved; every command would fail to launch with doctor saying nothing")
|
||||
}
|
||||
if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" {
|
||||
t.Errorf("setupStatus = %q, want runtime-root-unresolved so the unknown state is not reported as staleness: %s", status, result.Message)
|
||||
}
|
||||
if _, ok := result.Details["error"]; !ok {
|
||||
t.Errorf("the warning does not carry the resolver cause, so an operator cannot act on it: %+v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
// A CACHE DIRECTORY THAT RESOLVES TO NOTHING IS UNRESOLVED TOO, not only one
|
||||
// that fails to resolve. os.UserCacheDir succeeds on a value like "." and the
|
||||
// selection a command makes refuses it after canonicalizing, with "user cache
|
||||
// directory is unavailable". The diagnostic behind this check did not, so doctor
|
||||
// went on to compare the recorded root against candidates derived from an empty
|
||||
// cache root and reported staleness, which sends an operator to re-run setup on
|
||||
// a machine where setup cannot select a root either.
|
||||
//
|
||||
// The sandbox package pins the two entry points to one error through the
|
||||
// resolver seam on every platform. This is the consumer's half, and it is
|
||||
// Windows-only because that is where the environment can produce the value:
|
||||
// %LocalAppData% is returned as it is, while a relative XDG_CACHE_HOME is an
|
||||
// error on Linux and a relative HOME still joins to a usable path on macOS.
|
||||
// Reported by @jatmn.
|
||||
func TestDoctorReportsAnEmptyCacheRootAsUnresolvedNotStale(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("only %LocalAppData% hands os.UserCacheDir a value that canonicalizes to nothing")
|
||||
}
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
redirectUserCache(t)
|
||||
|
||||
runtimeRoot := doctorRuntimeCandidate(t, workspace)
|
||||
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
|
||||
t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
|
||||
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
|
||||
|
||||
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
|
||||
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
|
||||
t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message)
|
||||
}
|
||||
|
||||
for _, value := range []string{".", " . "} {
|
||||
t.Setenv("LOCALAPPDATA", value)
|
||||
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
|
||||
if result == nil {
|
||||
t.Fatalf("LOCALAPPDATA=%q: doctor reported a healthy sandbox while no command can select a runtime root", value)
|
||||
}
|
||||
if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" {
|
||||
t.Errorf("LOCALAPPDATA=%q: setupStatus = %q, want runtime-root-unresolved: %s", value, status, result.Message)
|
||||
}
|
||||
if cause, _ := result.Details["error"].(string); !strings.Contains(cause, "user cache directory is unavailable") {
|
||||
t.Errorf("LOCALAPPDATA=%q: the warning does not carry the resolver cause a command would report: %+v", value, result.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,7 @@ func (tool skillTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
available := make([]string, 0, len(merged))
|
||||
for _, skill := range merged {
|
||||
if skill.Name == name {
|
||||
return tools.Result{Status: tools.StatusOK, Output: skill.Content}
|
||||
return tools.Result{Status: tools.StatusOK, Output: skills.ToolOutput(skill)}
|
||||
}
|
||||
available = append(available, skill.Name)
|
||||
}
|
||||
|
||||
@@ -673,3 +673,28 @@ func TestExpandPluginRootPathRelative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The multi-root skill tool is the one the CLI registers, so it has to point at
|
||||
// a skill's bundled files the same way the core tool does (#584).
|
||||
func TestNewSkillToolPointsAtTheSkillsBundledFiles(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
dir := t.TempDir()
|
||||
writeTestSkill(t, dir, "pdf", "Run scripts/fill.py.")
|
||||
script := filepath.Join(dir, "pdf", "scripts", "fill.py")
|
||||
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := NewSkillTool(dir, nil).Run(context.Background(), map[string]any{"name": "pdf"})
|
||||
if got.Status != tools.StatusOK {
|
||||
t.Fatalf("Status = %s (output: %s)", got.Status, got.Output)
|
||||
}
|
||||
if !strings.Contains(got.Output, "- scripts/fill.py") || !strings.Contains(got.Output, filepath.Join(dir, "pdf")) {
|
||||
t.Fatalf("output does not point at the bundled script:\n%s", got.Output)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain points the sandbox runtime's user-cache root at test-owned storage
|
||||
// for the whole package.
|
||||
//
|
||||
// PLAN CONSTRUCTION CREATES DIRECTORIES, which is easy to miss because it reads
|
||||
// like naming. windowsSandboxProfileWithProvisionedRuntime provisions the root
|
||||
// it selects, and the simulated-Windows tests run on every platform, so a test
|
||||
// that supplies an explicit child environment but leaves the cache alone writes
|
||||
// into the developer's real one. Not hypothetical: the machine this was found on
|
||||
// had accumulated thousands of entries under the real runtime root from exactly
|
||||
// these runs.
|
||||
//
|
||||
// Done once for the package rather than per test, because the leak is in the
|
||||
// DEFAULT: any new test that builds a Windows plan is affected unless its author
|
||||
// remembers, and remembering is what failed here. A test that needs a specific
|
||||
// cache root still overrides sandboxUserCacheDir itself.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "zero-sandbox-testcache-")
|
||||
if err != nil {
|
||||
// Fail loudly rather than silently falling back to the real cache.
|
||||
panic("sandbox tests: create the test cache root: " + err.Error())
|
||||
}
|
||||
sandboxUserCacheDir = func() (string, error) { return root, nil }
|
||||
|
||||
code := m.Run()
|
||||
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -189,18 +189,7 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
}
|
||||
var runtimeCleanup func()
|
||||
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
|
||||
// The home THIS command asked for. Windows planning resolves
|
||||
// ZERO_WINDOWS_SANDBOX_HOME out of spec.Env and hands it to the runner for
|
||||
// marker validation, so selection has to read the same environment or the
|
||||
// two disagree about which marker describes the tree. See
|
||||
// pinnedSandboxRuntimeRoot.
|
||||
commandSandboxHome := ""
|
||||
if spec.Env != nil {
|
||||
if resolved, err := ResolveWindowsSandboxHome(envListToMap(spec.Env)); err == nil {
|
||||
commandSandboxHome = resolved
|
||||
}
|
||||
}
|
||||
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot, commandSandboxHome)
|
||||
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
|
||||
if runtimeErr != nil {
|
||||
return CommandPlan{}, runtimeErr
|
||||
}
|
||||
|
||||
@@ -393,11 +393,10 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
|
||||
|
||||
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
|
||||
t.Helper()
|
||||
setupPlan, err := BuildWindowsSandboxSetupArgs(options)
|
||||
args, err := BuildWindowsSandboxSetupArgs(options)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
args := setupPlan.Args
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, setupExe, args...)
|
||||
@@ -407,30 +406,8 @@ func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSand
|
||||
}
|
||||
}
|
||||
|
||||
// prepareWindowsRealSmokeCommand gives an elevated-tier smoke command the profile
|
||||
// the planner would have given it. The elevated runner validates the command
|
||||
// against the setup marker, and setup fingerprinted a profile with the selected
|
||||
// runtime folded in, so a command built from the bare profile can only be
|
||||
// refused. The unelevated tier applies its own plan and has no marker to agree
|
||||
// with, so it is left exactly as the test wrote it.
|
||||
func prepareWindowsRealSmokeCommand(t *testing.T, base WindowsSandboxCommandArgsOptions) (WindowsSandboxCommandArgsOptions, func()) {
|
||||
t.Helper()
|
||||
if base.SandboxLevel != WindowsSandboxLevelRestrictedToken || base.PermissionProfile.Runtime != nil {
|
||||
return base, func() {}
|
||||
}
|
||||
workspace := firstNonEmpty(base.WorkspaceRoots...)
|
||||
prepared, release, err := plannerPreparedWindowsProfile(workspace, base.SandboxHome, base.PermissionProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare the smoke command the way the planner does: %v", err)
|
||||
}
|
||||
base.PermissionProfile = prepared
|
||||
return base, release
|
||||
}
|
||||
|
||||
func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantCode int) {
|
||||
t.Helper()
|
||||
base, release := prepareWindowsRealSmokeCommand(t, base)
|
||||
defer release()
|
||||
base.Command = command
|
||||
args, err := BuildWindowsSandboxCommandArgs(base)
|
||||
if err != nil {
|
||||
@@ -456,8 +433,6 @@ func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSand
|
||||
// explicit unsupported-mode rejections rather than sandboxed command failures).
|
||||
func runWindowsRealSmokeCommandExpectError(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantSubstr ...string) {
|
||||
t.Helper()
|
||||
base, release := prepareWindowsRealSmokeCommand(t, base)
|
||||
defer release()
|
||||
base.Command = command
|
||||
args, err := BuildWindowsSandboxCommandArgs(base)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE TWO HALVES OF A ROLLBACK RECORD MUST DESCRIBE ONE OBJECT.
|
||||
//
|
||||
// The snapshot used to read the identity through a handle, close it, and then
|
||||
// re-resolve the pathname to read the stamp. A rename between those pairs one
|
||||
// directory's identity with another's bytes, and a rollback that correctly
|
||||
// proves it holds the first then writes the second's contents into it.
|
||||
//
|
||||
// The window itself needs an elevated installer racing an unelevated renamer and
|
||||
// is not reproducible here, so this pins the contract the binding provides: both
|
||||
// facts come back together, and they agree with the object actually at the path.
|
||||
func TestStampSnapshotPairsIdentityWithItsOwnBytes(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const contents = "the stamp that belongs to this directory"
|
||||
if err := os.WriteFile(windowsSandboxRuntimeStampPath(root, testStampPlanHash), []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
identity, identified, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
existed := state == runtimeStampPresent
|
||||
if !identified {
|
||||
t.Fatal("the snapshot established no identity for a directory that exists")
|
||||
}
|
||||
if !existed || string(prior) != contents {
|
||||
t.Fatalf("prior stamp = %q existed=%v, want %q", string(prior), existed, contents)
|
||||
}
|
||||
if direct, ok := runtimeDirIdentity(root); !ok || direct != identity {
|
||||
t.Errorf("snapshot identity %q does not describe the directory at the path (%q)", identity, direct)
|
||||
}
|
||||
}
|
||||
|
||||
// An absent stamp still establishes the identity, because that came from the
|
||||
// directory handle and not from the stamp read.
|
||||
func TestStampSnapshotIdentifiesARootWithNoStamp(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, identified, _, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
existed := state == runtimeStampPresent
|
||||
if !identified || identity == "" {
|
||||
t.Error("a root with no stamp established no identity")
|
||||
}
|
||||
if existed {
|
||||
t.Error("reported a prior stamp that does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// A created directory's identity must come from the creation, so the ledger
|
||||
// cannot record an object this run did not make.
|
||||
func TestCreatedDirectoryIdentityDescribesWhatWasCreated(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "created")
|
||||
|
||||
identity, identified, err := createRuntimeDirIdentified(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !identified || identity == "" {
|
||||
t.Fatal("creation established no identity")
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("the directory was not created: %v", statErr)
|
||||
}
|
||||
if direct, ok := runtimeDirIdentity(path); !ok || direct != identity {
|
||||
t.Errorf("creation identity %q does not describe the directory now at the path (%q)", identity, direct)
|
||||
}
|
||||
|
||||
// Creating over something that exists is the caller's already-handled signal.
|
||||
if _, _, again := createRuntimeDirIdentified(path); !os.IsExist(again) {
|
||||
t.Errorf("creating over an existing directory returned %v, want an IsExist error", again)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ABSENCE IS CLASSIFIED IN THE PRODUCER'S DOMAIN.
|
||||
//
|
||||
// The stamp is opened through NtCreateFile, so a stamp that is not there comes
|
||||
// back as STATUS_OBJECT_NAME_NOT_FOUND. Compensation compared it with the Win32
|
||||
// codes only. An NTStatus is a different type and errors.Is does not convert it,
|
||||
// so removing a stamp that never existed was reported as a rollback failure.
|
||||
//
|
||||
// Native, against real directories: the question is what the API returns at this
|
||||
// boundary, and a synthetic error value would only test the comparison.
|
||||
|
||||
func runtimeRootForCompensation(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abcd")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(root)
|
||||
if !ok {
|
||||
t.Fatal("identify the runtime root")
|
||||
}
|
||||
return root, identity
|
||||
}
|
||||
|
||||
// A runtime directory that exists and holds no stamp: removal has nothing to do
|
||||
// and says so by succeeding.
|
||||
func TestRemovingAStampThatIsNotThereSucceeds(t *testing.T) {
|
||||
root, identity := runtimeRootForCompensation(t)
|
||||
name := windowsSandboxRuntimeStampName(testStampPlanHash)
|
||||
|
||||
if err := compensateRuntimeStampBound(root, identity, name, nil, false); err != nil {
|
||||
t.Fatalf("removing a stamp that was never written reported a failure: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Errorf("the runtime directory itself was disturbed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh setup that fails BEFORE its stamp is written, composed the way the
|
||||
// setup transaction composes it: a real snapshot of the untouched tree, and the
|
||||
// compensation run with the original failure as its cause. The cause has to come
|
||||
// back alone. A second error about the stamp sends the operator looking for
|
||||
// residue that does not exist.
|
||||
func TestAFreshSetupFailureBeforeTheStampReportsOnlyItsCause(t *testing.T) {
|
||||
root, _ := runtimeRootForCompensation(t)
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if snapshot.priorState != runtimeStampAbsent || !snapshot.rootIdentified {
|
||||
t.Fatalf("SETUP INVALID: the snapshot did not record an identified root with no stamp: %+v", snapshot)
|
||||
}
|
||||
|
||||
cause := errors.New("windows ACL target does not exist: an earlier entry in the plan")
|
||||
reported := runWindowsSandboxSetupCompensations(cause, nil, windowsRuntimeRootRollback{stamp: snapshot})
|
||||
|
||||
if reported == nil || !errors.Is(reported, cause) {
|
||||
t.Fatalf("the original failure is no longer visible: %v", reported)
|
||||
}
|
||||
if text := reported.Error(); strings.Contains(text, "rollback failed") || strings.Contains(text, "setup stamp") {
|
||||
t.Errorf("compensation added a failure of its own for a stamp that never existed:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// AND ONLY ABSENCE. Something at the stamp's name that cannot be opened as the
|
||||
// stamp is not "already removed", and treating every failed open as success
|
||||
// would report a clean undo over residue nobody looked at. A directory at that
|
||||
// name is a failure the real API produces on demand.
|
||||
func TestAnUninspectableStampIsStillReported(t *testing.T) {
|
||||
root, identity := runtimeRootForCompensation(t)
|
||||
name := windowsSandboxRuntimeStampName(testStampPlanHash)
|
||||
if err := os.Mkdir(filepath.Join(root, name), 0o700); err != nil {
|
||||
t.Fatalf("put a directory where the stamp would be: %v", err)
|
||||
}
|
||||
|
||||
err := compensateRuntimeStampBound(root, identity, name, nil, false)
|
||||
if err == nil {
|
||||
t.Fatal("compensation reported success over something it could not open as the stamp")
|
||||
}
|
||||
if isWindowsNotFound(err) {
|
||||
t.Errorf("the failure classifies as not-found, so it would be swallowed: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, name)); statErr != nil {
|
||||
t.Errorf("the object it could not inspect was removed anyway: %v", statErr)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// COMPENSATION MUST PROVE IT HOLDS THE OBJECT IT CHANGED.
|
||||
//
|
||||
// The forward apply and its stamp go through one handle, so they are provably
|
||||
// about one object. Compensation runs later, after those handles have closed,
|
||||
// and used to resolve the pathname again. Rename the original aside, put an
|
||||
// ordinary directory at the name, and a pathname-only undo strips a stamp from
|
||||
// the substitute, or writes bytes snapshotted from another object onto it, and
|
||||
// then removes it as though this run had created it, while the moved original
|
||||
// keeps this run's grant and stamp.
|
||||
func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "runtime-root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stampPath := windowsSandboxRuntimeStampPath(root, testStampPlanHash)
|
||||
if err := os.WriteFile(stampPath, []byte("previous-plan-hash"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if snapshot.priorState != runtimeStampPresent {
|
||||
t.Fatal("SETUP INVALID: the snapshot did not record the pre-existing stamp")
|
||||
}
|
||||
|
||||
// The original is moved aside and an ordinary directory takes the name.
|
||||
moved := filepath.Join(parent, "moved-aside")
|
||||
if err := os.Rename(root, moved); err != nil {
|
||||
t.Skipf("cannot rename the runtime root on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
substituteStamp := filepath.Join(root, filepath.Base(stampPath))
|
||||
if err := os.WriteFile(substituteStamp, []byte("not-ours"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = snapshot.restore()
|
||||
if err == nil {
|
||||
t.Fatal("compensation mutated a directory it never touched and reported success")
|
||||
}
|
||||
for _, want := range []string{"no longer the directory", "replacement"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not say what was left behind (%q): %v", want, err)
|
||||
}
|
||||
}
|
||||
if body, readErr := os.ReadFile(substituteStamp); readErr != nil || string(body) != "not-ours" {
|
||||
t.Errorf("the substitute's stamp was overwritten: %q %v", string(body), readErr)
|
||||
}
|
||||
if body, readErr := os.ReadFile(filepath.Join(moved, filepath.Base(stampPath))); readErr != nil || string(body) != "previous-plan-hash" {
|
||||
t.Errorf("the original lost its stamp: %q %v", string(body), readErr)
|
||||
}
|
||||
}
|
||||
|
||||
// And the created-directory ledger applies the same rule, or the stamp check
|
||||
// just moves the damage one line down.
|
||||
func TestCreatedDirectoryCompensationRefusesAReplacement(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "created-root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}
|
||||
|
||||
moved := filepath.Join(parent, "moved")
|
||||
if err := os.Rename(root, moved); err != nil {
|
||||
t.Skipf("cannot rename on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := rollback.run()
|
||||
if err == nil {
|
||||
t.Fatal("a substitute directory was removed as though this run had created it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no longer the directory this run created") {
|
||||
t.Errorf("the error does not name the replacement: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(root); statErr != nil {
|
||||
t.Errorf("the substitute was removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case still cleans up, or the guard would be refusing everything.
|
||||
func TestCompensationStillRemovesWhatThisRunCreated(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "mine")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}
|
||||
if err := rollback.run(); err != nil {
|
||||
t.Fatalf("compensation refused a directory it really did create: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Errorf("the directory this run created survived compensation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// runtimeCompensationSwapSeam exists so the shared compensation code compiles
|
||||
// everywhere. Only the Windows build closes a check-then-mutate window, because
|
||||
// only there does setup run elevated against a tree an unelevated process can
|
||||
// rename.
|
||||
var runtimeCompensationSwapSeam func()
|
||||
|
||||
func compensateRuntimeStampBound(root string, identity string, name string, prior []byte, existed bool) error {
|
||||
current, ok := runtimeDirIdentity(root)
|
||||
if !ok {
|
||||
return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", root)
|
||||
}
|
||||
if current != identity {
|
||||
return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+
|
||||
"leaving the replacement untouched, and the original still carries this run's stamp", root)
|
||||
}
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
if !existed {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, prior, 0o600); err != nil {
|
||||
return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeCreatedRuntimeDirBound(path string, identity string) error {
|
||||
current, ok := runtimeDirIdentity(path)
|
||||
if !ok {
|
||||
// ONLY ABSENCE MEANS THERE IS NOTHING TO UNDO. Any lookup failure used to
|
||||
// return here, so a directory that exists but could not be identified was
|
||||
// reported as cleanly removed while it stayed on disk. The Windows build
|
||||
// already draws this line, and the rollback it serves promises to report
|
||||
// what it could not remove. Reported by CodeRabbit.
|
||||
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("sandbox runtime root %s was created by this run but could not be identified for removal; leaving it in place", path)
|
||||
}
|
||||
if current != identity {
|
||||
return fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+
|
||||
"leaving the replacement in place", path)
|
||||
}
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ONLY ABSENCE IS NOTHING TO UNDO.
|
||||
//
|
||||
// removeCreatedRuntimeDirBound returned success on any failure to identify the
|
||||
// directory, so one that was still there, but could not be looked at, was
|
||||
// reported as removed. Here the parent loses search permission, which makes the
|
||||
// directory exist and be unidentifiable at the same time.
|
||||
func TestRemovingAnUnidentifiableCreatedDirectoryIsReported(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses directory search permission, so the lookup cannot be made to fail")
|
||||
}
|
||||
parent := filepath.Join(t.TempDir(), "zero")
|
||||
created := filepath.Join(parent, "runtime")
|
||||
if err := os.MkdirAll(created, 0o700); err != nil {
|
||||
t.Fatalf("create the directory: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(created)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
if err := os.Chmod(parent, 0o600); err != nil {
|
||||
t.Fatalf("withdraw search permission: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
if _, err := os.Lstat(created); err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("SETUP INVALID: the directory should exist and be unidentifiable, Lstat said %v", err)
|
||||
}
|
||||
|
||||
err := removeCreatedRuntimeDirBound(created, identity)
|
||||
if err == nil {
|
||||
t.Fatal("a created directory that could not be identified was reported as removed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not be identified") {
|
||||
t.Errorf("the error does not say why the directory was left: %v", err)
|
||||
}
|
||||
|
||||
// And it is still there, which is why the error is owed.
|
||||
if err := os.Chmod(parent, 0o700); err != nil {
|
||||
t.Fatalf("restore search permission: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(created); err != nil {
|
||||
t.Fatalf("the directory was gone after all, so the error was not owed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Absence stays a clean undo, or the test above would be satisfied by a function
|
||||
// that reports every lookup failure.
|
||||
func TestRemovingACreatedDirectoryThatIsAlreadyGoneSucceeds(t *testing.T) {
|
||||
created := filepath.Join(t.TempDir(), "runtime")
|
||||
if err := os.Mkdir(created, 0o700); err != nil {
|
||||
t.Fatalf("create the directory: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(created)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
if err := os.Remove(created); err != nil {
|
||||
t.Fatalf("remove the directory: %v", err)
|
||||
}
|
||||
if err := removeCreatedRuntimeDirBound(created, identity); err != nil {
|
||||
t.Fatalf("a created directory that is already gone was reported as a failed undo: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// swapDuringCompensation installs a seam that runs once, between the identity
|
||||
// check and the mutation, and replaces root with a fresh directory carrying a
|
||||
// file of its own.
|
||||
func swapDuringCompensation(t *testing.T, root string, aside string) *string {
|
||||
t.Helper()
|
||||
fired := false
|
||||
previous := runtimeCompensationSwapSeam
|
||||
t.Cleanup(func() { runtimeCompensationSwapSeam = previous })
|
||||
runtimeCompensationSwapSeam = func() {
|
||||
if fired {
|
||||
return
|
||||
}
|
||||
fired = true
|
||||
if err := os.Rename(root, aside); err != nil {
|
||||
t.Logf("could not rename the original aside: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the substitute: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "substitute.txt"), []byte("belongs to whoever put it here"), 0o600); err != nil {
|
||||
t.Fatalf("seed the substitute: %v", err)
|
||||
}
|
||||
}
|
||||
return &aside
|
||||
}
|
||||
|
||||
// A REPLACEMENT AFTER THE CHECK MUST NOT REACH THE SUBSTITUTE.
|
||||
//
|
||||
// Compensation used to read the identity through a handle, close it, and then
|
||||
// resolve the pathname again for the write or the delete. A rename plus a
|
||||
// replacement in that interval made the comparison true about one object while
|
||||
// the mutation landed on another, under elevation. Holding the handle across
|
||||
// both means the mutation follows the object that was verified, and whatever now
|
||||
// answers to the name is untouched.
|
||||
func TestStampCompensationDoesNotReachASubstitute(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "zero", "runtime", "v1", "abcd")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil {
|
||||
t.Fatalf("write the stamp: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(root)
|
||||
if !ok {
|
||||
t.Fatal("identify the runtime root")
|
||||
}
|
||||
aside := filepath.Join(parent, "renamed-aside")
|
||||
swapDuringCompensation(t, root, aside)
|
||||
|
||||
// Removal of a stamp this run wrote, i.e. the fresh-setup rollback.
|
||||
_ = compensateRuntimeStampBound(root, identity, windowsSandboxRuntimeStampName(testStampPlanHash), nil, false)
|
||||
|
||||
// Whatever the outcome, the substitute is not this run's business.
|
||||
substitute := filepath.Join(root, "substitute.txt")
|
||||
if _, err := os.Stat(substitute); err != nil {
|
||||
t.Errorf("compensation removed a file from the substitute directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(windowsSandboxRuntimeStampPath(root, testStampPlanHash)); err == nil {
|
||||
t.Error("compensation created a stamp inside the substitute directory")
|
||||
}
|
||||
// And the original, which is the object that was verified, is the one that
|
||||
// lost the stamp this run wrote.
|
||||
if _, err := os.Stat(windowsSandboxRuntimeStampPath(aside, testStampPlanHash)); err == nil {
|
||||
t.Error("the stamp this run wrote is still on the original object, so compensation followed the name instead")
|
||||
}
|
||||
}
|
||||
|
||||
// The same for the delete, where following the name would remove a directory
|
||||
// this run never created.
|
||||
func TestDirectoryCompensationDoesNotRemoveASubstitute(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
created := filepath.Join(parent, "created")
|
||||
if err := os.MkdirAll(created, 0o700); err != nil {
|
||||
t.Fatalf("create the directory: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(created)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
aside := filepath.Join(parent, "renamed-aside")
|
||||
swapDuringCompensation(t, created, aside)
|
||||
|
||||
_ = removeCreatedRuntimeDirBound(created, identity)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(created, "substitute.txt")); err != nil {
|
||||
t.Errorf("compensation removed the substitute directory's contents: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ONLY NOT-FOUND IS EVIDENCE OF REMOVAL.
|
||||
//
|
||||
// The verification probe used to read any Lstat error as absence, so a sharing
|
||||
// violation, an access denial, or an entry left delete-pending by another
|
||||
// process that holds a share-delete handle all reported complete compensation
|
||||
// for a directory that is still there. A holder able to clear the disposition
|
||||
// could then make the "removed" object visible again, and setup would already
|
||||
// have said the rollback finished.
|
||||
//
|
||||
// The three outcomes are distinguished with a probe seam, because a real
|
||||
// filesystem will not produce the third on demand.
|
||||
func TestDeletionVerificationDistinguishesAllThreeOutcomes(t *testing.T) {
|
||||
previous := runtimeCompensationStat
|
||||
t.Cleanup(func() { runtimeCompensationStat = previous })
|
||||
|
||||
newDir := func() (string, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "created")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(dir)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
return dir, identity
|
||||
}
|
||||
|
||||
t.Run("absence is proven and reported as success", func(t *testing.T) {
|
||||
dir, identity := newDir()
|
||||
runtimeCompensationStat = func(string) (fs.FileInfo, error) {
|
||||
return nil, &os.PathError{Op: "lstat", Path: dir, Err: os.ErrNotExist}
|
||||
}
|
||||
if err := removeCreatedRuntimeDirBound(dir, identity); err != nil {
|
||||
t.Errorf("a proven-absent directory was reported as a failure: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("still present is reported", func(t *testing.T) {
|
||||
dir, identity := newDir()
|
||||
runtimeCompensationStat = func(path string) (fs.FileInfo, error) { return os.Stat(".") }
|
||||
err := removeCreatedRuntimeDirBound(dir, identity)
|
||||
if err == nil {
|
||||
t.Fatal("a directory still present after deletion was reported as removed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "still present") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unverifiable probe is residue, not success", func(t *testing.T) {
|
||||
dir, identity := newDir()
|
||||
// What a share-delete holder produces: neither absence nor presence.
|
||||
runtimeCompensationStat = func(path string) (fs.FileInfo, error) {
|
||||
return nil, &os.PathError{Op: "lstat", Path: path, Err: errors.New("Access is denied.")}
|
||||
}
|
||||
err := removeCreatedRuntimeDirBound(dir, identity)
|
||||
if err == nil {
|
||||
t.Fatal("an unverifiable removal was reported as complete compensation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not be verified") {
|
||||
t.Errorf("the error does not say the removal is unproven: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE HANDLE IS THE OBJECT. A pathname is not.
|
||||
//
|
||||
// Compensation used to read the directory identity through a handle, close it,
|
||||
// and then resolve the pathname again for the mutation. A rename followed by a
|
||||
// replacement between those two steps makes the comparison true about one
|
||||
// object while the write or delete lands on another, and this runs elevated, so
|
||||
// a redirected pathname gives the mutation reach the replacer does not have
|
||||
// directly.
|
||||
//
|
||||
// Opening once and keeping the handle open across BOTH the identity check and
|
||||
// the mutation removes the interval. The child operations are relative to that
|
||||
// handle, so no ancestor is re-resolved either.
|
||||
|
||||
// runtimeCompensationSwapSeam runs between the identity check and the mutation.
|
||||
// Nil in production; a test installs one to replace the directory in exactly the
|
||||
// window this design closes.
|
||||
var runtimeCompensationSwapSeam func()
|
||||
|
||||
// runtimeCompensationStat is the post-deletion existence probe. A var so a test
|
||||
// can produce the third outcome, an inspection that neither proves absence nor
|
||||
// presence, which no real filesystem produces on demand.
|
||||
var runtimeCompensationStat = os.Lstat
|
||||
|
||||
type fileDispositionInfo struct {
|
||||
DeleteFile byte
|
||||
}
|
||||
|
||||
func handleRuntimeIdentity(handle windows.Handle) (string, error) {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil
|
||||
}
|
||||
|
||||
// openVerifiedRuntimeDirectory opens path without following a link at the final
|
||||
// component and returns it only if it is still the object identity names.
|
||||
func openVerifiedRuntimeDirectory(path string, identity string, access uint32, what string) (windows.Handle, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
access|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 {
|
||||
return 0, err
|
||||
}
|
||||
current, err := handleRuntimeIdentity(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("identify the sandbox runtime directory %s for compensation: %w", path, err)
|
||||
}
|
||||
if current != identity {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("sandbox runtime root %s is no longer the directory this run %s; "+
|
||||
"leaving the replacement in place, and the original still carries this run's changes", path, what)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// markForDeletion queues the object the handle names for removal. It never
|
||||
// resolves a pathname, so it can only reach what this process already holds.
|
||||
func markForDeletion(handle windows.Handle) error {
|
||||
info := fileDispositionInfo{DeleteFile: 1}
|
||||
return windows.SetFileInformationByHandle(
|
||||
handle,
|
||||
windows.FileDispositionInfo,
|
||||
(*byte)(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
)
|
||||
}
|
||||
|
||||
// compensateRuntimeStampBound restores or removes the stamp through a handle on
|
||||
// the directory whose identity still matches.
|
||||
//
|
||||
// RESTORED WITHOUT A GAP. This used to delete the stamp and then recreate the
|
||||
// previous one, so a command validating in between, or a process stopping
|
||||
// there, found no stamp at all where the previous setup had left a valid one.
|
||||
// The ordinary writer replaces the stamp in one step and puts the protected
|
||||
// DACL back, which a raw write would not, so a restore is just a publish of the
|
||||
// previous bytes. Only the branch that had no stamp before deletes, because
|
||||
// absent is the state it restores. Reported by @jatmn.
|
||||
func compensateRuntimeStampBound(root string, identity string, name string, prior []byte, existed bool) error {
|
||||
directory, err := openVerifiedRuntimeDirectory(root, identity,
|
||||
windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE, "stamped")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(directory)
|
||||
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
|
||||
if !existed {
|
||||
return deleteRuntimeStampChild(directory, name)
|
||||
}
|
||||
// Through the ordinary writer so it is protected again, and so the reader ACE
|
||||
// is resolved the same way a fresh setup resolves it.
|
||||
if err := writeWindowsRuntimeStampToDirectoryHandle(directory, name, string(prior)); err != nil {
|
||||
return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRuntimeStampChild removes the stamp relative to an already verified
|
||||
// directory handle. A stamp that is not there is the desired end state; anything
|
||||
// else is reported rather than swallowed.
|
||||
func deleteRuntimeStampChild(directory windows.Handle, name string) error {
|
||||
stamp, err := openWindowsChildNoFollow(directory, name,
|
||||
windows.DELETE|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE)
|
||||
if err != nil {
|
||||
// ABSENCE IS CLASSIFIED BY THE PRODUCER'S DOMAIN. openWindowsChildNoFollow
|
||||
// goes through NtCreateFile, so a stamp that is not there arrives as
|
||||
// STATUS_OBJECT_NAME_NOT_FOUND. This compared against the Win32 codes only,
|
||||
// and an NTStatus is a different type that errors.Is does not convert, so a
|
||||
// fresh setup that failed before writing any stamp reported a second,
|
||||
// spurious rollback failure for removing a file that never existed. The
|
||||
// snapshot path already answers this question for both domains, and using
|
||||
// the same answer keeps snapshot and compensation agreeing about what
|
||||
// absence means. Reported by @jatmn.
|
||||
if isWindowsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
|
||||
}
|
||||
if err := markForDeletion(stamp); err != nil {
|
||||
_ = windows.CloseHandle(stamp)
|
||||
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
|
||||
}
|
||||
// The entry goes when the last handle closes.
|
||||
_ = windows.CloseHandle(stamp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeCreatedRuntimeDirBound removes a directory this run created, through a
|
||||
// handle on the object identity names.
|
||||
func removeCreatedRuntimeDirBound(path string, identity string) error {
|
||||
handle, err := openVerifiedRuntimeDirectory(path, identity, windows.DELETE, "created")
|
||||
if err != nil {
|
||||
if isWindowsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
if err := markForDeletion(handle); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)
|
||||
}
|
||||
// The entry goes when the last handle closes, so releasing ours is what makes
|
||||
// the removal observable.
|
||||
_ = windows.CloseHandle(handle)
|
||||
// REPORTED SUCCESS IS NOT PROOF. A handle-bound operation accepting the call
|
||||
// has been seen not to take effect (PR #751, the promote rename), so the
|
||||
// outcome is checked rather than assumed.
|
||||
//
|
||||
// THREE OUTCOMES, NOT TWO. This used to read any Lstat error as absence, so a
|
||||
// sharing violation, an access denial, or a delete-pending entry held open by
|
||||
// another process all reported complete compensation for a directory that is
|
||||
// still there. A holder that clears the disposition can then make the
|
||||
// "removed" object visible again. Only not-found is evidence of removal;
|
||||
// everything else is at best unproven and has to be reported as residue.
|
||||
_, statErr := runtimeCompensationStat(path)
|
||||
switch {
|
||||
case errors.Is(statErr, os.ErrNotExist):
|
||||
return nil
|
||||
case statErr == nil:
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: it is still present after the deletion was accepted", path)
|
||||
default:
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: its removal could not be verified, so it may still be present: %w", path, statErr)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// runtimeIdentityAfterCreate resolves a just-created runtime directory's
|
||||
// identity BY PATHNAME, which is a second resolution of the same name and
|
||||
// therefore a window: the directory created can be renamed away and an ordinary
|
||||
// one substituted before this runs, and the ledger then records the substitute
|
||||
// for a directory the run never made.
|
||||
//
|
||||
// It is the honest implementation off Windows, where the elevated-installer
|
||||
// versus unelevated-renamer split this closes does not apply. On Windows the
|
||||
// creation returns the handle and identity is read from that, so nothing there
|
||||
// may call this: a Windows test asserts the count is zero, which is what makes
|
||||
// "the creation establishes the identity" a checked property rather than a
|
||||
// comment.
|
||||
var runtimeIdentityAfterCreate = runtimeDirIdentity
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "os"
|
||||
|
||||
// createRuntimeDirIdentified keeps the pathname form off Windows, where the
|
||||
// elevated-installer-versus-unelevated-renamer split this closes does not apply.
|
||||
func createRuntimeDirIdentified(path string) (string, bool, error) {
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
identity, ok := runtimeIdentityAfterCreate(path)
|
||||
return identity, ok, nil
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createRuntimeDirIdentified creates path and returns the identity of the
|
||||
// directory it created, read from the handle the creation itself returned.
|
||||
//
|
||||
// THE CREATION MUST BE THE THING THAT ESTABLISHES IDENTITY. os.Mkdir followed by
|
||||
// runtimeDirIdentity(path) is two resolutions of one name: Mkdir creates A, and
|
||||
// the reopen can land on a B substituted in between, so the rollback ledger
|
||||
// records B's identity for a directory this run never made. Compensation then
|
||||
// correctly proves it holds B and deletes it, while A keeps this run's ACL and
|
||||
// stamp under a name nothing is tracking. The runtime parent belongs to the
|
||||
// ordinary user, so that substitution needs no privilege.
|
||||
//
|
||||
// Win32 CreateFile cannot create a directory at all, whatever disposition it is
|
||||
// given, so an earlier attempt to do this with CREATE_NEW fell through to the
|
||||
// Mkdir-plus-reopen path on EVERY real creation and the documented handle
|
||||
// contract never once held. NtCreateFile with FILE_CREATE and
|
||||
// FILE_DIRECTORY_FILE does create one, and returns the handle to it.
|
||||
//
|
||||
// If the atomic create cannot be completed, this returns an error rather than
|
||||
// manufacturing an ownership record from a reopen: an unidentified create must
|
||||
// stop setup before privileged state is applied, not enter the ledger as though
|
||||
// it were proven.
|
||||
func createRuntimeDirIdentified(path string) (string, bool, error) {
|
||||
clean := filepath.Clean(path)
|
||||
parentPath, leaf := filepath.Split(clean)
|
||||
leaf = filepath.Clean(leaf)
|
||||
parentPath = filepath.Clean(parentPath)
|
||||
if leaf == "" || leaf == "." || parentPath == clean {
|
||||
return "", false, fmt.Errorf("sandbox runtime path %s has no component to create", path)
|
||||
}
|
||||
|
||||
// The parent is either a directory that already existed when the missing
|
||||
// components were computed, or one this same loop created a moment ago.
|
||||
parent, err := openWindowsDirectoryByName(parentPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("open sandbox runtime parent %s: %w", parentPath, err)
|
||||
}
|
||||
defer windows.CloseHandle(parent)
|
||||
|
||||
handle, err := createWindowsChildDirectory(parent, leaf)
|
||||
if err != nil {
|
||||
// A collision is the same signal os.Mkdir gives for a component another
|
||||
// process won the race to create, and the caller already treats that as
|
||||
// "not ours" (windows_setup.go). Returned as a *PathError so BOTH
|
||||
// os.IsExist, which the caller uses and which does not unwrap %w, and
|
||||
// errors.Is recognize it; wrapping with %w alone would have turned a
|
||||
// benign race into a hard setup failure.
|
||||
if errors.Is(err, windows.STATUS_OBJECT_NAME_COLLISION) {
|
||||
return "", false, &os.PathError{Op: "mkdir", Path: clean, Err: os.ErrExist}
|
||||
}
|
||||
return "", false, fmt.Errorf("create sandbox runtime directory %s: %w", clean, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
identity, idErr := handleRuntimeIdentity(handle)
|
||||
if idErr != nil {
|
||||
return "", false, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", clean, idErr)
|
||||
}
|
||||
return identity, true, nil
|
||||
}
|
||||
|
||||
// removeWindowsObjectByHandle deletes the object this handle is open on.
|
||||
//
|
||||
// THROUGH THE HANDLE, NOT THE NAME. Every caller is on a failure path partway
|
||||
// through a creation, and the one object it may remove is the one it just made
|
||||
// and is still holding. Re-resolving the name to delete it is the substitution
|
||||
// hole this whole descent exists to close, and here it would be a pathname
|
||||
// authoritative delete of somebody else's directory.
|
||||
//
|
||||
// The handle needs DELETE access, which the creating opens above ask for.
|
||||
func removeWindowsObjectByHandle(handle windows.Handle) error {
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
deleteIt := byte(1)
|
||||
return windows.NtSetInformationFile(handle, &iosb, &deleteIt, 1, windows.FileDispositionInformation)
|
||||
}
|
||||
|
||||
// createWindowsChildDirectory creates exactly one directory component beneath
|
||||
// parent and returns the handle to the object it created.
|
||||
//
|
||||
// Relative to a handle, so no ancestor is re-resolved and there is no interval
|
||||
// for a swap to land in. FILE_CREATE fails rather than opening anything that is
|
||||
// already there, so the returned handle cannot describe a pre-existing object,
|
||||
// and FILE_OPEN_REPARSE_POINT keeps the failure honest if one is.
|
||||
func createWindowsChildDirectory(parent windows.Handle, name string) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode sandbox runtime 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 iosb windows.IO_STATUS_BLOCK
|
||||
// DELETE because the caller has to be able to undo this creation through the
|
||||
// handle it just got, without going back through the name. See
|
||||
// removeWindowsObjectByHandle.
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_CREATE,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CREATION IS THE OWNERSHIP BOUNDARY, NOT THE STEP AFTER IT.
|
||||
//
|
||||
// Acquisition learns it created a directory or a lease file at the create
|
||||
// itself, but used to publish that fact only once every later step had also
|
||||
// succeeded: the identity read for a directory, and the inspection, wrapping and
|
||||
// locking for a lease. A failure in between returned an error while leaving the
|
||||
// object on disk with nothing above holding a record of it. Setup then reported
|
||||
// failure and left invocation-owned state behind, and where an ancestor HAD been
|
||||
// recorded, that ancestor's compensation failed afterwards on a child it could
|
||||
// not account for.
|
||||
//
|
||||
// Both platforms are driven through acquireRuntimeLeaseForPlatform, the call
|
||||
// production makes, rather than through either descent directly.
|
||||
|
||||
var errInjectedRuntimeFailure = errors.New("injected failure after the create")
|
||||
|
||||
// failRuntimeCreationAt makes the step after the creation of the named object
|
||||
// fail, which is the only way to reach the paths under test.
|
||||
func failRuntimeCreationAt(t *testing.T, match string) {
|
||||
t.Helper()
|
||||
runtimeCreationFailure = func(created string) error {
|
||||
if filepath.Base(created) == match {
|
||||
return errInjectedRuntimeFailure
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { runtimeCreationFailure = nil })
|
||||
}
|
||||
|
||||
// runtimeRootForAcquisition returns a root whose owned tail does not exist yet,
|
||||
// so acquisition has to create it.
|
||||
func runtimeRootForAcquisition(t *testing.T) string {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(base, "zero", "runtime"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.Join(base, "zero", "runtime", "v1", "digest")
|
||||
}
|
||||
|
||||
// A directory whose identity could not be read is removed, not abandoned.
|
||||
func TestAcquisitionUndoesADirectoryItCreatedButCouldNotIdentify(t *testing.T) {
|
||||
root := runtimeRootForAcquisition(t)
|
||||
version := filepath.Dir(root)
|
||||
failRuntimeCreationAt(t, filepath.Base(version))
|
||||
|
||||
lease, created, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
if !errors.Is(err, errInjectedRuntimeFailure) {
|
||||
t.Fatalf("acquisition failed for some other reason: %v", err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Errorf("acquisition returned a ledger for a failed create: %v", created)
|
||||
}
|
||||
if _, statErr := os.Stat(version); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s was created by this run and left behind after the failure, with nothing recording it (stat: %v)", version, statErr)
|
||||
}
|
||||
// And what was already there is untouched.
|
||||
if _, statErr := os.Stat(filepath.Dir(version)); statErr != nil {
|
||||
t.Errorf("a directory that existed before the run was removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// So is a lease file this run created but never got to lock.
|
||||
func TestAcquisitionUndoesALeaseFileItCreatedButCouldNotLock(t *testing.T) {
|
||||
root := runtimeRootForAcquisition(t)
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
failRuntimeCreationAt(t, filepath.Base(leasePath))
|
||||
|
||||
lease, _, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
if !errors.Is(err, errInjectedRuntimeFailure) {
|
||||
t.Fatalf("acquisition failed for some other reason: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(leasePath); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s was created by this run and left behind after the failure, so its directory can never be compensated (stat: %v)", leasePath, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// A LEASE FILE THAT WAS ALREADY THERE IS SOMEBODY ELSE'S.
|
||||
//
|
||||
// The undo is keyed on this call having created the object, so an acquisition
|
||||
// that merely opened an existing lease must not remove it on its way out.
|
||||
func TestAcquisitionLeavesALeaseFileItDidNotCreate(t *testing.T) {
|
||||
root := runtimeRootForAcquisition(t)
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
if err := os.WriteFile(leasePath, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failRuntimeCreationAt(t, filepath.Base(leasePath))
|
||||
|
||||
lease, _, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
if _, statErr := os.Stat(leasePath); statErr != nil {
|
||||
t.Errorf("acquisition removed a lease file it did not create: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE LEDGER STILL COVERS WHAT SURVIVES A LATER FAILURE.
|
||||
//
|
||||
// Undoing the component that failed must not undo the ones before it: those are
|
||||
// recorded, and compensation is what removes them.
|
||||
func TestAcquisitionKeepsTheLedgerForComponentsItFinished(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "zero", "runtime", "v1", "digest")
|
||||
failRuntimeCreationAt(t, "v1")
|
||||
|
||||
lease, created, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
var recorded []string
|
||||
for _, entry := range created {
|
||||
recorded = append(recorded, filepath.Base(entry.path))
|
||||
}
|
||||
// "zero" and "runtime" were created and identified before the injected
|
||||
// failure, so they belong to the caller to compensate.
|
||||
if len(recorded) != 2 || recorded[0] != "zero" || recorded[1] != "runtime" {
|
||||
t.Errorf("the completed components are not on the ledger the caller compensates from: %v", recorded)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(base, "zero", "runtime")); statErr != nil {
|
||||
t.Errorf("a component that was created and recorded was removed anyway: %v", statErr)
|
||||
}
|
||||
if failed := filepath.Join(base, "zero", "runtime", "v1"); !isMissing(failed) {
|
||||
t.Errorf("%s failed after being created and was left behind", failed)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "v1") {
|
||||
t.Errorf("the error does not name the component that failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isMissing(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// createRuntimeTailHandleRelative creates the owned tail through the same
|
||||
// no-follow boundary the lease uses.
|
||||
//
|
||||
// This was an os.Mkdir loop over full pathnames, on the reasoning that the
|
||||
// elevated-installer-versus-unelevated-renamer split the Windows descent closes
|
||||
// does not apply here. That is true of the elevation asymmetry and false of the
|
||||
// substitution: the fallback root is a predictable name under a temp directory
|
||||
// every local account can write to, so another user can put a link at an owned
|
||||
// component and have this loop create the rest of the tree inside it. Same
|
||||
// descent, same refusal, one implementation per platform rather than one
|
||||
// protected platform.
|
||||
func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) {
|
||||
created, parent, err := createRuntimeTailRetainingFD(base, tail)
|
||||
if parent >= 0 {
|
||||
_ = unix.Close(parent)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// The two seams the rooted descent exposes, shared by both platform
|
||||
// implementations so a test asserts the same property on either.
|
||||
//
|
||||
// They live apart from the Windows descent they started in because the POSIX
|
||||
// fallback now takes the same shape, and a seam defined next to one of two
|
||||
// implementations is how the other one quietly ends up untested.
|
||||
|
||||
// runtimeDescentBarrier, when set, runs after the base directory has been opened
|
||||
// and before the first owned component is touched. It exists so a test can swap
|
||||
// an owned component for a link at exactly the point the old pathname walk was
|
||||
// vulnerable, and prove the redirected target is never created or granted. Nil in
|
||||
// production.
|
||||
var runtimeDescentBarrier func()
|
||||
|
||||
// runtimeCreationFailure, when set, is consulted immediately after this run
|
||||
// creates a runtime object and before the step that would publish it: a
|
||||
// directory component's identity read, or the lease file's inspection, wrapping
|
||||
// and locking. Returning an error stands in for that step failing.
|
||||
//
|
||||
// It is the only way to reach those paths, and they are the ones where the
|
||||
// transaction knows it created something that nothing above it knows about yet.
|
||||
// The argument is the created component's path, or the lease file's name. Nil in
|
||||
// production.
|
||||
var runtimeCreationFailure func(string) error
|
||||
|
||||
// runtimeCleanupExclusivityBarrier, when set, runs during compensation while the
|
||||
// exclusive cleanup lease is held: after it has been taken and before the first
|
||||
// thing is removed. It exists so a test can put a contender into that window and
|
||||
// prove the exclusion still covers it, which is precisely where the old shape had
|
||||
// already handed the lease back. Nil in production.
|
||||
var runtimeCleanupExclusivityBarrier func()
|
||||
|
||||
// runtimeBaseOpenedByName, when set, receives the ONE path this descent opens by
|
||||
// name. The whole security property is which path that is: the fixed cache or
|
||||
// temp directory above the owned tail, never a predictable component Zero owns. A
|
||||
// test can assert it directly instead of inferring it from whether a swap
|
||||
// happened to be caught, which is not discriminating. Nil in production.
|
||||
var runtimeBaseOpenedByName func(string)
|
||||
@@ -1,171 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// createRuntimeTailRetainingFD creates the owned tail of the runtime root
|
||||
// beneath base, one component at a time, from retained descriptors.
|
||||
//
|
||||
// THE NAME IS NOT AUTHORIZATION, ON THIS SIDE EITHER.
|
||||
//
|
||||
// The fallback root moved from an atomically minted os.MkdirTemp parent to a
|
||||
// predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
|
||||
// because setup and every later process have to agree on one root without
|
||||
// talking to each other, but it also means another local account can name the
|
||||
// first owned component before this one does. refuseAliasedRuntimeComponents
|
||||
// answers about a component that is ABSENT by saying there is nothing to alias,
|
||||
// and the code then called os.MkdirAll on the parent and opened <root>.lease by
|
||||
// full pathname. Both follow a link planted after that answer, so the guard was
|
||||
// authorizing writes it could not see the destination of.
|
||||
//
|
||||
// So the base is opened by name exactly once, and every component below it is
|
||||
// created or opened relative to a retained descriptor with O_NOFOLLOW. A link at
|
||||
// an owned component is then an ELOOP or ENOTDIR from the kernel rather than a
|
||||
// redirection nobody noticed, and no owned name is ever resolved twice.
|
||||
//
|
||||
// The base itself is opened WITHOUT O_NOFOLLOW. It belongs to the operator and is
|
||||
// legitimately a link on macOS, where /tmp resolves through /private. Only the
|
||||
// tail Zero creates is held to the stricter rule.
|
||||
func createRuntimeTailRetainingFD(base string, tail []string) ([]windowsCreatedRuntimeDir, int, error) {
|
||||
if runtimeBaseOpenedByName != nil {
|
||||
runtimeBaseOpenedByName(base)
|
||||
}
|
||||
parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, -1, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
|
||||
if runtimeDescentBarrier != nil {
|
||||
runtimeDescentBarrier()
|
||||
}
|
||||
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for _, path := range tail {
|
||||
name := filepath.Base(path)
|
||||
child, madeIt, openErr := openOrCreateRuntimeChildNoFollow(parent, name)
|
||||
if openErr != nil {
|
||||
_ = unix.Close(parent)
|
||||
return created, -1, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr)
|
||||
}
|
||||
// CREATED, AND ABOUT TO BE FORGOTTEN. Both checks below run before the
|
||||
// ledger entry is appended, so failing either used to return a directory
|
||||
// this run had made with nothing above holding a record of it: setup
|
||||
// reported failure and left it behind, and a recorded ancestor's
|
||||
// compensation then failed on a child it could not explain.
|
||||
//
|
||||
// Undone relative to the parent descriptor rather than published to the
|
||||
// ledger, because on the identity path the identity the ledger needs is the
|
||||
// thing that just failed, and the descriptor is the only authority here
|
||||
// that is not a name.
|
||||
undoCreation := func(err error) error {
|
||||
if !madeIt {
|
||||
return err
|
||||
}
|
||||
if undoErr := unix.Unlinkat(parent, name, unix.AT_REMOVEDIR); undoErr != nil {
|
||||
return fmt.Errorf("%w; and the directory this run created there could not be removed: %w", err, undoErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := refuseForeignRuntimeDirectory(child, path); err != nil {
|
||||
err = undoCreation(err)
|
||||
_ = unix.Close(child)
|
||||
_ = unix.Close(parent)
|
||||
return created, -1, err
|
||||
}
|
||||
if madeIt {
|
||||
identity, idErr := runtimeDirectoryIdentity(child)
|
||||
if idErr == nil && runtimeCreationFailure != nil {
|
||||
idErr = runtimeCreationFailure(path)
|
||||
}
|
||||
if idErr != nil {
|
||||
err := undoCreation(fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr))
|
||||
_ = unix.Close(child)
|
||||
_ = unix.Close(parent)
|
||||
return created, -1, err
|
||||
}
|
||||
created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true})
|
||||
}
|
||||
_ = unix.Close(parent)
|
||||
parent = child
|
||||
}
|
||||
return created, parent, nil
|
||||
}
|
||||
|
||||
// openOrCreateRuntimeChildNoFollow opens name under parent, creating it if it is
|
||||
// not there, and never follows a link at that name.
|
||||
//
|
||||
// madeIt distinguishes the directory this process created from one that was
|
||||
// already there, because only the former belongs on the rollback ledger.
|
||||
func openOrCreateRuntimeChildNoFollow(parent int, name string) (fd int, madeIt bool, err error) {
|
||||
const flags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
|
||||
child, openErr := unix.Openat(parent, name, flags, 0)
|
||||
if openErr == nil {
|
||||
return child, false, nil
|
||||
}
|
||||
if !errors.Is(openErr, unix.ENOENT) {
|
||||
// ELOOP or ENOTDIR here IS the finding: a link sits where a directory of
|
||||
// ours should be. Returned as-is rather than translated, because the two
|
||||
// platforms spell it differently and the caller only needs it to fail.
|
||||
return -1, false, openErr
|
||||
}
|
||||
if mkErr := unix.Mkdirat(parent, name, 0o700); mkErr != nil {
|
||||
if !errors.Is(mkErr, unix.EEXIST) {
|
||||
return -1, false, mkErr
|
||||
}
|
||||
// Somebody created it between the open and the create. That is an ordinary
|
||||
// race with another Zero process, and the reopen below is still no-follow,
|
||||
// so a link that arrived in the same window is refused rather than used.
|
||||
child, openErr = unix.Openat(parent, name, flags, 0)
|
||||
if openErr != nil {
|
||||
return -1, false, openErr
|
||||
}
|
||||
return child, false, nil
|
||||
}
|
||||
child, openErr = unix.Openat(parent, name, flags, 0)
|
||||
if openErr != nil {
|
||||
return -1, false, openErr
|
||||
}
|
||||
return child, true, nil
|
||||
}
|
||||
|
||||
// refuseForeignRuntimeDirectory proves the directory reached belongs to this
|
||||
// user and is not group- or world-writable.
|
||||
//
|
||||
// Asked of the DESCRIPTOR, so it describes the object the descent is holding
|
||||
// rather than whatever the name resolves to now. Ownership is the part that
|
||||
// matters on a shared temp root: a component another account created first is
|
||||
// theirs, and continuing into it would put the sandbox runtime inside a tree they
|
||||
// control even though nothing about it is a link.
|
||||
func refuseForeignRuntimeDirectory(fd int, path string) error {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
return fmt.Errorf("inspect sandbox runtime component %s: %w", path, err)
|
||||
}
|
||||
if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) {
|
||||
return fmt.Errorf("refusing to use the sandbox runtime component %s: it is owned by uid %d, not %d, so another account chose what the sandbox writes into",
|
||||
path, stat.Uid, uid)
|
||||
}
|
||||
if stat.Mode&(unix.S_IWGRP|unix.S_IWOTH) != 0 {
|
||||
return fmt.Errorf("refusing to use the sandbox runtime component %s: mode %#o lets another account replace what is inside it",
|
||||
path, stat.Mode&0o7777)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeDirectoryIdentity names the object behind a descriptor, so rollback can
|
||||
// tell the directory it created from one that replaced it under the same name.
|
||||
func runtimeDirectoryIdentity(fd int) (string, error) {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.FormatUint(uint64(stat.Dev), 10) + ":" + strconv.FormatUint(uint64(stat.Ino), 10), nil
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createRuntimeTailHandleRelative creates the owned tail of the runtime root
|
||||
// beneath base, one component at a time, from retained handles.
|
||||
//
|
||||
// THE NAME IS NOT AUTHORIZATION. The previous shape found the deepest existing
|
||||
// ancestor with os.Stat and then created each missing component by opening its
|
||||
// parent BY NAME. Both follow a junction, so a local user could replace the
|
||||
// predictable owned "zero" component with a junction after the pre-check,
|
||||
// point it anywhere, let elevated setup create runtime\v1\<hash> beneath that
|
||||
// target, and put the original back before the post-check. The post-check then
|
||||
// saw an ordinary path, the plan applied normally, and rollback reported the
|
||||
// separately created target only as identity-mismatched residue.
|
||||
//
|
||||
// So the base is opened by name exactly once, and everything below it is
|
||||
// addressed relative to a handle with FILE_OPEN_REPARSE_POINT: an existing
|
||||
// component is opened no-follow and refused if it is a link, and a missing one
|
||||
// is created relative to its parent's handle and identified from the handle the
|
||||
// create returned. No component name is resolved twice, so there is no interval
|
||||
// for a swap to land in, and a junction placed at any owned component is seen
|
||||
// as the link it is rather than followed.
|
||||
//
|
||||
// Redirected cache and TEMP locations ABOVE the owned tail are still allowed:
|
||||
// they are part of base, which is the operator's business. The restriction is
|
||||
// on the zero/runtime/v1/<hash> components Zero itself owns.
|
||||
func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) {
|
||||
created, parent, err := createRuntimeTailRetainingHandle(base, tail)
|
||||
if parent != 0 {
|
||||
_ = windows.CloseHandle(parent)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
// createRuntimeTailRetainingHandle is the same descent, but it hands the caller
|
||||
// the retained handle to the deepest component instead of closing it.
|
||||
//
|
||||
// Lease acquisition needs that handle: the lease file is a sibling of the
|
||||
// runtime root, so creating it by pathname would re-resolve the owned components
|
||||
// the descent just verified, which is the whole interval this design removes.
|
||||
// The caller owns the returned handle and must close it, including when an error
|
||||
// is returned alongside a non-zero handle.
|
||||
func createRuntimeTailRetainingHandle(base string, tail []string) ([]windowsCreatedRuntimeDir, windows.Handle, error) {
|
||||
if runtimeBaseOpenedByName != nil {
|
||||
runtimeBaseOpenedByName(base)
|
||||
}
|
||||
parent, err := openWindowsDirectoryByName(base)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
|
||||
if runtimeDescentBarrier != nil {
|
||||
runtimeDescentBarrier()
|
||||
}
|
||||
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for _, path := range tail {
|
||||
name := filepath.Base(path)
|
||||
// Exists already: another process won the race, or it was there before
|
||||
// setup began. Opened no-follow, so a junction here is refused rather than
|
||||
// descended, and it is not ours to record.
|
||||
existing, openErr := openWindowsChildNoFollow(parent, name,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
|
||||
if openErr == nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = existing
|
||||
continue
|
||||
}
|
||||
if !isWindowsNotFound(openErr) {
|
||||
return created, parent, fmt.Errorf("inspect sandbox runtime component %s: %w", path, openErr)
|
||||
}
|
||||
handle, createErr := createWindowsChildDirectory(parent, name)
|
||||
if createErr != nil {
|
||||
if errors.Is(createErr, windows.STATUS_OBJECT_NAME_COLLISION) {
|
||||
// Created between the open and the create by someone else. Same
|
||||
// answer as the os.IsExist branch the caller already had: not ours,
|
||||
// but not a failure either. Reopen no-follow so the descent
|
||||
// continues through a verified object rather than a name.
|
||||
existing, reopenErr := openWindowsChildNoFollow(parent, name,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
|
||||
if reopenErr != nil {
|
||||
return created, parent, fmt.Errorf("inspect sandbox runtime component %s after a concurrent create: %w", path, reopenErr)
|
||||
}
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = existing
|
||||
continue
|
||||
}
|
||||
return created, parent, fmt.Errorf("create sandbox runtime root %s: %w", path, createErr)
|
||||
}
|
||||
identity, idErr := handleRuntimeIdentity(handle)
|
||||
if idErr == nil && runtimeCreationFailure != nil {
|
||||
idErr = runtimeCreationFailure(path)
|
||||
}
|
||||
if idErr != nil {
|
||||
// CREATED, AND ABOUT TO BE FORGOTTEN. The ledger entry is appended on
|
||||
// the next line, so returning here left a directory this run had made
|
||||
// with nothing above holding a record of it: setup reports failure and
|
||||
// leaves it behind, and if an ancestor WAS recorded, that ancestor's
|
||||
// compensation then fails on a directory it cannot explain.
|
||||
//
|
||||
// Undone through the handle rather than published to the ledger,
|
||||
// because the identity the ledger needs is the thing that just failed.
|
||||
err := fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr)
|
||||
if undoErr := removeWindowsObjectByHandle(handle); undoErr != nil {
|
||||
err = fmt.Errorf("%w; and the directory this run created there could not be removed: %w", err, undoErr)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
return created, parent, err
|
||||
}
|
||||
created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true})
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = handle
|
||||
}
|
||||
return created, parent, nil
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE SWAP THE OLD WALK COULD NOT SEE.
|
||||
//
|
||||
// createRuntimeDirRecording used to find the deepest existing ancestor with
|
||||
// os.Stat and then create each missing component by opening its parent BY
|
||||
// NAME. Both follow a junction. So between the pre-check and the create, a
|
||||
// local user could replace the predictable owned "zero" component with a
|
||||
// junction into a directory of their choosing, and elevated setup would create
|
||||
// runtime\v1\<hash> beneath that target, apply its ACL plan there, and then
|
||||
// find an ordinary path at the post-check once the original was put back.
|
||||
//
|
||||
// This plants the junction at the deterministic barrier between opening the
|
||||
// base and touching the first owned component, which is that exact interval,
|
||||
// and proves two things: nothing is created beneath the redirected target, and
|
||||
// the descent refuses rather than continuing through the link. A junction
|
||||
// needs no privilege on Windows, which is why it is the shape used here rather
|
||||
// than a symlink that would skip on an unelevated box.
|
||||
func TestRuntimeDescentRefusesAJunctionSwappedIntoAnOwnedComponent(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := t.TempDir()
|
||||
|
||||
zero := filepath.Join(base, "zero")
|
||||
tail := []string{zero, filepath.Join(zero, "runtime")}
|
||||
|
||||
previous := runtimeDescentBarrier
|
||||
runtimeDescentBarrier = func() {
|
||||
out, err := exec.Command("cmd", "/c", "mklink", "/J", zero, target).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { runtimeDescentBarrier = previous })
|
||||
|
||||
created, err := createRuntimeTailHandleRelative(base, tail)
|
||||
if err == nil {
|
||||
t.Fatalf("the descent continued through a junction and reported success: created=%v", created)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Errorf("a junction-swapped component still produced ownership records: %v", created)
|
||||
}
|
||||
|
||||
// The whole point: the redirected target must be untouched.
|
||||
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.Errorf("setup created %v beneath the junction target, which is somebody else's directory", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And the honest control: with no swap, the same descent creates the tail,
|
||||
// records an identity for each component from the creation handle, and the
|
||||
// result matches what the name resolves to afterwards.
|
||||
func TestRuntimeDescentCreatesTheOwnedTailFromHandles(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
zero := filepath.Join(base, "zero")
|
||||
tail := []string{zero, filepath.Join(zero, "runtime"), filepath.Join(zero, "runtime", "v1")}
|
||||
|
||||
created, err := createRuntimeTailHandleRelative(base, tail)
|
||||
if err != nil {
|
||||
t.Fatalf("descent: %v", err)
|
||||
}
|
||||
if len(created) != len(tail) {
|
||||
t.Fatalf("created %d components, want %d: %v", len(created), len(tail), created)
|
||||
}
|
||||
for i, record := range created {
|
||||
if record.path != tail[i] {
|
||||
t.Errorf("record %d path = %q, want %q", i, record.path, tail[i])
|
||||
}
|
||||
if !record.identified {
|
||||
t.Errorf("record %d for %s carries no identity", i, record.path)
|
||||
continue
|
||||
}
|
||||
if now, ok := runtimeDirIdentity(record.path); !ok || now != record.identity {
|
||||
t.Errorf("record %d identity %q does not describe the directory at %s (%q)", i, record.identity, record.path, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// runtimeDirIdentity identifies the directory currently at path.
|
||||
//
|
||||
// device plus inode, read with Lstat so a link substituted at the final
|
||||
// component is identified as the link rather than followed. The Windows build
|
||||
// cannot use os.SameFile for this and explains why; here the same eager capture
|
||||
// keeps both platforms on one rule.
|
||||
func runtimeDirIdentity(path string) (string, bool) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino), true
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// runtimeDirIdentity identifies the directory currently at path, EAGERLY.
|
||||
//
|
||||
// os.SameFile cannot be used for this on Windows. A Windows fileStat loads its
|
||||
// volume serial and file index lazily, by PATHNAME, at comparison time, so an
|
||||
// identity captured before a replacement and compared afterwards reports the
|
||||
// substitute as the same object and the original as a different one: exactly
|
||||
// backwards, and silently. Measured, not reasoned about.
|
||||
//
|
||||
// Reading the identity through a handle at capture time removes the lazy step.
|
||||
// Opened with FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at the final
|
||||
// component is identified as the link it is rather than followed.
|
||||
func runtimeDirIdentity(path string) (string, bool) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
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 {
|
||||
return "", false
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), true
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// THE COMMAND HAS TO RECOGNISE THE ROOT SETUP RECORDED, WHATEVER ITS
|
||||
// ENVIRONMENT SAYS.
|
||||
//
|
||||
// Setup derives the fallback root in an elevated terminal and records it; a
|
||||
// command re-derives it in an ordinary one to decide whether the record belongs
|
||||
// to this workspace. The user scope in that derivation used to come from
|
||||
// USERNAME, so a value that differed between the two terminals made the command
|
||||
// reject the record and select a root with no capability ACE on it.
|
||||
func TestRecordedFallbackRootSurvivesADifferentUsernameVariable(t *testing.T) {
|
||||
// Canonical, as selection hands it over. The record check canonicalizes on
|
||||
// its own, so a raw t.TempDir() spelling digests differently wherever TEMP is
|
||||
// an 8.3 short path, as it is on the CI runners.
|
||||
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMP", tempHome)
|
||||
t.Setenv("TEMP", tempHome)
|
||||
|
||||
t.Setenv("USERNAME", "setup-terminal")
|
||||
recorded, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("derive the fallback root as setup: %v", err)
|
||||
}
|
||||
if !ownedFallbackRuntimeRecord(workspace, recorded) {
|
||||
t.Fatalf("SETUP INVALID: the fallback root is not recognised even in the environment that derived it: %s", recorded)
|
||||
}
|
||||
|
||||
t.Setenv("USERNAME", "command-terminal")
|
||||
if !ownedFallbackRuntimeRecord(workspace, recorded) {
|
||||
t.Fatalf("once USERNAME changed, the command no longer recognises the fallback root setup recorded: %s", recorded)
|
||||
}
|
||||
derived, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("derive the fallback root as the command: %v", err)
|
||||
}
|
||||
if !sameWindowsRuntimeRootPath(derived, recorded) {
|
||||
t.Fatalf("setup and the command derived different fallback roots for one workspace:\n setup: %s\n command: %s", recorded, derived)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EVERY OWNERSHIP-CHECKED ANCESTOR MUST ALREADY BE INSIDE ONE USER'S NAMESPACE.
|
||||
//
|
||||
// The temp-derived fallback lives under a directory that is shared on Unix
|
||||
// whenever TMPDIR is unset. Runtime preparation creates and ownership-checks
|
||||
// each component of the tail at 0700, so a fixed first component meant the
|
||||
// first account to use the fallback created a private directory every other
|
||||
// account was then refused at: traversal fails on the mode, and relaxing the
|
||||
// mode fails the ownership guard instead. The per-workspace digest is the leaf,
|
||||
// so it never got the chance to separate them.
|
||||
//
|
||||
// The invariant is therefore about the SHALLOWEST checked component, not the
|
||||
// leaf: it has to carry the user scope, or the guards below it are guarding a
|
||||
// namespace two users share.
|
||||
func TestFallbackRuntimeRootScopesItsShallowestOwnedComponent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("os.TempDir() resolves inside the user profile on Windows, so the shared-temp collision cannot arise")
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempHome)
|
||||
|
||||
root, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
components := ownedRuntimeComponents(root)
|
||||
if len(components) == 0 {
|
||||
t.Fatal("no ownership-checked components derived for the fallback root")
|
||||
}
|
||||
// ownedRuntimeComponents walks upward, so the last entry is the shallowest
|
||||
// directory the guards create and validate.
|
||||
shallowest := filepath.Base(components[len(components)-1])
|
||||
scope := sandboxRuntimeUserScope()
|
||||
if !strings.Contains(shallowest, scope) {
|
||||
t.Errorf("the shallowest ownership-checked component is %q and does not carry the user scope %q; "+
|
||||
"two accounts on a shared temp would contend for it", shallowest, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// The workspace still decides the leaf, so setup and the command derive the same
|
||||
// root for the same workspace. Scoping the top must not have moved that.
|
||||
func TestFallbackRuntimeRootStaysStableForOneWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempHome)
|
||||
t.Setenv("TMP", tempHome)
|
||||
t.Setenv("TEMP", tempHome)
|
||||
|
||||
first, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Errorf("the fallback root is not stable for one workspace:\n %s\n %s", first, second)
|
||||
}
|
||||
|
||||
other, err := fallbackSandboxRuntimeRoot(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == first {
|
||||
t.Error("two different workspaces derived the same fallback root")
|
||||
}
|
||||
if filepath.Dir(other) != filepath.Dir(first) {
|
||||
t.Errorf("two workspaces for one user should differ only in the leaf:\n %s\n %s", first, other)
|
||||
}
|
||||
}
|
||||
|
||||
// And the Windows side is a deliberate choice, not an omission. Its temp root
|
||||
// is already inside the user profile, and the names have to stay fixed so
|
||||
// windowsSandboxRuntimeOwnedTail can still recognise a root that an elevated
|
||||
// setup running as a different account built.
|
||||
func TestFallbackOwnedNamesAreUnscopedOnWindowsOnly(t *testing.T) {
|
||||
names := sandboxRuntimeFallbackOwnedNames()
|
||||
if len(names) != len(windowsSandboxRuntimeOwnedNames) {
|
||||
t.Fatalf("fallback names = %v, want the same depth as %v", names, windowsSandboxRuntimeOwnedNames)
|
||||
}
|
||||
// The components below the first are shared by both platforms, so a change
|
||||
// to them would break the tail matcher on Windows.
|
||||
for index := 1; index < len(names); index++ {
|
||||
if names[index] != windowsSandboxRuntimeOwnedNames[index] {
|
||||
t.Errorf("component %d = %q, want %q", index, names[index], windowsSandboxRuntimeOwnedNames[index])
|
||||
}
|
||||
}
|
||||
scoped := names[0] != windowsSandboxRuntimeOwnedNames[0]
|
||||
if runtime.GOOS == "windows" && scoped {
|
||||
t.Errorf("the Windows fallback scoped its first component to %q; the tail matcher compares fixed names", names[0])
|
||||
}
|
||||
if runtime.GOOS != "windows" && !scoped {
|
||||
t.Errorf("the first component is %q on %s, where the temp root can be shared between accounts",
|
||||
names[0], runtime.GOOS)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeRecordedRoot puts a setup marker naming root under sandboxHome.
|
||||
func writeRecordedRoot(t *testing.T, sandboxHome, root string) {
|
||||
t.Helper()
|
||||
path := WindowsSandboxSetupMarkerPath(sandboxHome)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := json.Marshal(WindowsSandboxSetupMarker{SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, RuntimeRoot: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, blob, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ONE COMMAND, ONE SANDBOX HOME.
|
||||
//
|
||||
// Runtime preparation happens before Windows platform planning, and it used to
|
||||
// resolve the sandbox home from the AMBIENT environment while the planner
|
||||
// resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own spec.Env and hands
|
||||
// that one to the runner for marker validation. So a command that explicitly
|
||||
// selects home B, while the parent process still points at home A, pinned A's
|
||||
// recorded root into the profile; the runner then loaded B's marker, saw a
|
||||
// different root, and rejected the command as out of date even though setup for
|
||||
// B was valid. The two homes need no different derivation rules to disagree,
|
||||
// only different valid selections from the same preferred/fallback pair.
|
||||
func TestThePinnedRootComesFromTheCommandsOwnSandboxHome(t *testing.T) {
|
||||
homeA := t.TempDir()
|
||||
homeB := t.TempDir()
|
||||
preferred := filepath.Join(t.TempDir(), "preferred")
|
||||
fallback := filepath.Join(t.TempDir(), "fallback")
|
||||
|
||||
writeRecordedRoot(t, homeA, preferred)
|
||||
writeRecordedRoot(t, homeB, fallback)
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeB); got != fallback {
|
||||
t.Errorf("pinned %q for a command that selected home B, want %q: the ambient home decided instead of the command's", got, fallback)
|
||||
}
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeA); got != preferred {
|
||||
t.Errorf("pinned %q for home A, want %q", got, preferred)
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded root that this workspace could not select is still refused, which
|
||||
// is the protection that stops one workspace's tree being pinned into another.
|
||||
func TestARecordedRootFromAnotherWorkspaceIsStillRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "someone-elses-tree"))
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
|
||||
t.Errorf("pinned %q, want none: it matches neither candidate this workspace derives", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No command context means the ambient environment is the only authority there
|
||||
// is, so an empty home must still resolve rather than refusing outright.
|
||||
func TestAnEmptyCommandHomeFallsBackToTheAmbientEnvironment(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
preferred := filepath.Join(t.TempDir(), "preferred")
|
||||
writeRecordedRoot(t, home, preferred)
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, filepath.Join(t.TempDir(), "fallback"), ""); got != preferred {
|
||||
t.Errorf("pinned %q with no command home, want the ambient one to decide (%q)", got, preferred)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,31 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const sandboxRuntimeLeaseSuffix = ".lease"
|
||||
|
||||
// errRuntimeLeaseReplaced says a lock was taken on a lease object that the lease
|
||||
// name no longer resolves to: cleanup unlinked or replaced it while this call was
|
||||
// waiting. Both acquisitions retry from their open on it, and surface it only
|
||||
// once the retries are spent.
|
||||
var errRuntimeLeaseReplaced = errors.New("the sandbox runtime lease was removed or replaced while it was being taken")
|
||||
|
||||
// runtimeLeaseAcquireAttempts bounds those retries. Each stale lock means one
|
||||
// cleanup ran to completion in the window, and cleanups do not chain without
|
||||
// limit; a third stale object in a row is reported rather than chased.
|
||||
const runtimeLeaseAcquireAttempts = 3
|
||||
|
||||
// sandboxRuntimeLease is one holder's grip on a runtime root.
|
||||
//
|
||||
// createdFile says this acquisition is the one that brought the lease file into
|
||||
// existence. Compensation needs it: rollback refuses a non-empty directory, and
|
||||
// the lease sits beside the leaf inside a directory a failed setup has to be able
|
||||
// to remove, so the file has to go with it. It may only go if it was ours, which
|
||||
// is a fact only the create knows.
|
||||
type sandboxRuntimeLease struct {
|
||||
handle runtimeLeaseHandle
|
||||
once sync.Once
|
||||
// root is the runtime root this lease is for, kept so compensation can name
|
||||
// the lease file without deriving it again.
|
||||
root string
|
||||
// createdFile says THIS acquisition brought the lease file into existence.
|
||||
createdFile bool
|
||||
}
|
||||
|
||||
// createdLeaseFile reports whether compensation may remove the lease file.
|
||||
//
|
||||
// Only the acquisition that created it may, and only while nothing else holds
|
||||
// it. A lease that was already there belongs to whoever made it, and removing it
|
||||
// would take the coordination object out from under a live command.
|
||||
func (lease *sandboxRuntimeLease) createdLeaseFile() (string, bool) {
|
||||
if lease == nil || !lease.createdFile {
|
||||
return "", false
|
||||
}
|
||||
return sandboxRuntimeLeasePath(lease.root), true
|
||||
}
|
||||
|
||||
func sandboxRuntimeLeasePath(root string) string {
|
||||
return root + sandboxRuntimeLeaseSuffix
|
||||
}
|
||||
|
||||
// The pathname-based shared acquisition that used to live here is gone with its
|
||||
// two platform halves. It opened the lease by full pathname, so it followed a
|
||||
// link planted at that name and, on Windows, opened without FILE_SHARE_DELETE,
|
||||
// which alone stops cleanup from ever removing the lease it is holding. Every
|
||||
// caller now goes through acquireRuntimeLeaseForPlatform, which descends from a
|
||||
// retained parent handle no-follow. Leaving the weaker door defined next to the
|
||||
// stronger one is how a later change quietly takes it.
|
||||
|
||||
// appendCreatedRuntimeDirs merges one acquisition attempt's ledger into the
|
||||
// running one. A path seen again was re-created after cleanup removed the
|
||||
// earlier object, so the later identity supersedes; rollback verifies identity
|
||||
// before it removes anything, and a stale one would make it refuse the directory
|
||||
// this run actually made.
|
||||
func appendCreatedRuntimeDirs(existing, made []windowsCreatedRuntimeDir) []windowsCreatedRuntimeDir {
|
||||
for _, record := range made {
|
||||
replaced := false
|
||||
for index := range existing {
|
||||
if existing[index].path == record.path {
|
||||
existing[index] = record
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
existing = append(existing, record)
|
||||
}
|
||||
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
return existing
|
||||
return &sandboxRuntimeLease{handle: handle}, nil
|
||||
}
|
||||
|
||||
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
|
||||
handle, inUse, err := tryAcquireExclusiveRuntimeLease(root)
|
||||
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil || inUse {
|
||||
return nil, inUse, err
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// runtimeLeasePreCreateBarrier, when set, runs after lease acquisition has
|
||||
// decided what it is going to do and before it creates anything.
|
||||
//
|
||||
// It exists so a test can reproduce the CHECK-THEN-USE RACE rather than a
|
||||
// junction that was already there when acquisition started. The two are not the
|
||||
// same finding: a junction present from the outset was already refused by the
|
||||
// alias pre-check, while one planted in this window defeated it, because
|
||||
// os.MkdirAll and the lease open both resolved the component again and followed
|
||||
// it. A test that only plants the junction up front passes against the defective
|
||||
// implementation and proves nothing.
|
||||
//
|
||||
// Nil in production.
|
||||
var runtimeLeasePreCreateBarrier func()
|
||||
@@ -1,150 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// leaseRootUnder builds a production-shaped runtime root beneath a test-owned
|
||||
// cache directory, so the owned tail is real rather than assumed.
|
||||
func leaseRootUnder(t *testing.T, cacheRoot string) string {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", cacheRoot)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// THE LEASE IS THE FIRST WRITE SETUP MAKES, SO IT IS THE ONE THAT MATTERS MOST.
|
||||
//
|
||||
// prepareSandboxRuntimeLease checked the owned components for aliases and then
|
||||
// created the parent with os.MkdirAll and opened "<root>.lease" by pathname.
|
||||
// Both follow. An ordinary same-account process could replace "zero", "runtime"
|
||||
// or "v1" with a junction after the check and before either call, and elevated
|
||||
// setup would then build the tree and the lease file inside somebody else's
|
||||
// target. Putting the component back afterwards left the later handle-relative
|
||||
// provisioning working on the legitimate tree, so no post-check ever saw it, and
|
||||
// the rollback could not compensate what it had no record of.
|
||||
//
|
||||
// A junction needs no privilege on Windows, so the caller's half of this runs on
|
||||
// an ordinary unelevated box.
|
||||
func TestRuntimeLeaseRefusesAJunctionPlantedAfterTheCheck(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
target := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")
|
||||
if !strings.HasPrefix(strings.ToLower(root), strings.ToLower(owned)) {
|
||||
t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned)
|
||||
}
|
||||
|
||||
// THE JUNCTION ARRIVES IN THE WINDOW, WHICH IS THE WHOLE POINT.
|
||||
//
|
||||
// One that is already there when acquisition starts was refused by the old
|
||||
// alias pre-check too, so a test that plants it up front passes against the
|
||||
// defective implementation and proves nothing. This one lands after the
|
||||
// decision and before the first create, which is exactly where os.MkdirAll and
|
||||
// the pathname lease open used to follow it.
|
||||
previous := runtimeLeasePreCreateBarrier
|
||||
t.Cleanup(func() { runtimeLeasePreCreateBarrier = previous })
|
||||
attempted, planted := false, false
|
||||
runtimeLeasePreCreateBarrier = func() {
|
||||
if attempted {
|
||||
return
|
||||
}
|
||||
attempted = true
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil {
|
||||
t.Logf("mklink /J unavailable: %v: %s", err, out)
|
||||
return
|
||||
}
|
||||
planted = true
|
||||
}
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if !attempted {
|
||||
t.Skip("the pre-create barrier never ran, so the race was not reproduced")
|
||||
}
|
||||
// Bound to mklink's result, not to the attempt. When it fails, acquisition
|
||||
// goes on to create an ordinary directory at the same name, the check below
|
||||
// finds it, and a correct acquisition would be failed as a junction bypass.
|
||||
if !planted {
|
||||
t.Skip("mklink /J could not create the junction here, so the race was not reproduced")
|
||||
}
|
||||
if _, statErr := os.Lstat(owned); statErr != nil {
|
||||
t.Skipf("the junction could not be created here: %v", statErr)
|
||||
}
|
||||
|
||||
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("lease acquisition wrote %v beneath a junction planted after its own check; err=%v", names, err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("lease acquisition reported success through a junction planted after its check")
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary cache tree still gets its lease, or the refusal above would be
|
||||
// satisfied by an acquirer that refuses everything.
|
||||
func TestRuntimeLeaseStillAcquiresOnAnOrdinaryTree(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary cache tree was refused: %v", err)
|
||||
}
|
||||
defer lease.release()
|
||||
|
||||
if _, statErr := os.Stat(sandboxRuntimeLeasePath(root)); statErr != nil {
|
||||
t.Fatalf("the lease file was not created: %v", statErr)
|
||||
}
|
||||
// The components above the leaf are recorded, so a later failure can undo
|
||||
// them. The leaf itself belongs to provisioning and must NOT appear here.
|
||||
for _, entry := range created {
|
||||
if strings.EqualFold(filepath.Clean(entry.path), filepath.Clean(root)) {
|
||||
t.Fatalf("lease acquisition claimed the runtime leaf %s, which provisioning owns", root)
|
||||
}
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("SETUP INVALID: nothing was recorded as created, so the accounting assertion above is vacuous")
|
||||
}
|
||||
}
|
||||
|
||||
// A second acquisition over an existing tree records nothing: it created nothing,
|
||||
// so it owns nothing, and rollback must not remove a tree it found.
|
||||
func TestRuntimeLeaseRecordsOnlyWhatItCreated(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
first, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquisition: %v", err)
|
||||
}
|
||||
first.release()
|
||||
|
||||
second, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("second acquisition: %v", err)
|
||||
}
|
||||
defer second.release()
|
||||
if len(created) != 0 {
|
||||
t.Fatalf("the second acquisition claimed %d directories it did not create; rollback would delete a tree it found", len(created))
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// unixLeaseRootUnder builds a production-shaped runtime root beneath a
|
||||
// test-owned base, so the owned tail is real rather than assumed.
|
||||
func unixLeaseRootUnder(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(base))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", base)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// countEntriesUnder reports how many entries exist anywhere beneath dir, so a
|
||||
// test can say "nothing was written here" rather than checking one name.
|
||||
func countEntriesUnder(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
var found []string
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
found = append(found, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", dir, err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// A PRE-CHECK CANNOT AUTHORIZE A PATHNAME WRITE THAT COMES AFTER IT.
|
||||
//
|
||||
// The fallback root moved from an atomically minted MkdirTemp parent to a
|
||||
// predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
|
||||
// because setup and every later process have to agree on one root without talking
|
||||
// to each other, but it also means another local account can name the first owned
|
||||
// component first. refuseAliasedRuntimeComponents answers about an ABSENT
|
||||
// component by saying there is nothing to alias, which is the state a fresh
|
||||
// fallback is in, and os.MkdirAll plus the pathname lease open then followed a
|
||||
// link planted after that answer.
|
||||
//
|
||||
// The link arrives after the last validation and before the first create, which
|
||||
// is the only window that matters: one planted earlier was refused by the old
|
||||
// pre-check too, so a test that plants it up front passes against the defect and
|
||||
// proves nothing.
|
||||
func TestRuntimeLeaseRefusesALinkPlantedAfterTheCheck(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
owned := filepath.Join(canonicalSandboxWorkspaceRoot(base), "zero")
|
||||
if !pathWithinRoot(owned, root) {
|
||||
t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned)
|
||||
}
|
||||
|
||||
previous := runtimeDescentBarrier
|
||||
t.Cleanup(func() { runtimeDescentBarrier = previous })
|
||||
planted := false
|
||||
runtimeDescentBarrier = func() {
|
||||
if planted {
|
||||
return
|
||||
}
|
||||
planted = true
|
||||
if err := os.Symlink(target, owned); err != nil {
|
||||
t.Errorf("plant the link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
|
||||
// SETUP: the swap really happened in the window, or nothing was under test.
|
||||
if !planted {
|
||||
t.Fatal("SETUP INVALID: the descent never reached the barrier, so no link was planted")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("lease acquisition succeeded through a link planted at the first owned component")
|
||||
}
|
||||
// ELOOP on Linux, and either that or ENOTDIR elsewhere. Asserted as a class
|
||||
// rather than one kernel's spelling.
|
||||
if !errors.Is(err, unix.ELOOP) && !errors.Is(err, unix.ENOTDIR) {
|
||||
t.Errorf("refused for the wrong reason, want a no-follow refusal: %v", err)
|
||||
}
|
||||
|
||||
// THE POINT IS WHERE THE WRITES DID NOT GO. An error alone would also be
|
||||
// produced by a later guard noticing the link after the tree was built inside
|
||||
// somebody else's directory.
|
||||
if leaked := countEntriesUnder(t, target); len(leaked) != 0 {
|
||||
t.Fatalf("the redirected target received %v; the refusal happened after the writes rather than instead of them", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: an ordinary tree still gets its lease, and the one path opened by name
|
||||
// is the operator's base rather than a component Zero owns.
|
||||
//
|
||||
// Refusing everything would satisfy the test above. Asserting the base directly
|
||||
// is what makes this discriminating: inferring it from whether a swap was caught
|
||||
// says nothing about which name was trusted.
|
||||
func TestRuntimeLeaseOpensOnlyTheBaseByName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
previousBase := runtimeBaseOpenedByName
|
||||
t.Cleanup(func() { runtimeBaseOpenedByName = previousBase })
|
||||
var byName []string
|
||||
runtimeBaseOpenedByName = func(path string) { byName = append(byName, path) }
|
||||
|
||||
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("lease acquisition failed on an ordinary tree: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
|
||||
if len(byName) != 1 {
|
||||
t.Fatalf("opened %v by name, want exactly the base", byName)
|
||||
}
|
||||
if got := byName[0]; got != canonicalSandboxWorkspaceRoot(base) && got != base {
|
||||
t.Fatalf("opened %q by name, want the base %q", got, base)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("the descent created the owned components but recorded none of them, so rollback has nothing to undo")
|
||||
}
|
||||
if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err != nil {
|
||||
t.Fatalf("no lease file was created on an ordinary tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And a link at the LEASE NAME itself is refused by both holders, so the shared
|
||||
// and exclusive sides cannot end up locking different objects.
|
||||
func TestRuntimeLeaseRefusesALinkAtTheLeaseName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP: cannot seed a lease: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
if err := os.Remove(leasePath); err != nil {
|
||||
t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "target.lease")
|
||||
if err := os.WriteFile(target, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, leasePath); err != nil {
|
||||
t.Fatalf("SETUP: cannot plant the link: %v", err)
|
||||
}
|
||||
|
||||
shared, _, sharedErr := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if shared != nil {
|
||||
shared.release()
|
||||
}
|
||||
if sharedErr == nil {
|
||||
t.Error("shared acquisition accepted a link at the lease name")
|
||||
}
|
||||
|
||||
cleanup, inUse, cleanupErr := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if cleanup != nil {
|
||||
cleanup.release()
|
||||
}
|
||||
if cleanupErr == nil {
|
||||
t.Errorf("cleanup accepted a link at the lease name (inUse=%t); it would treat a lock on %s as proof the root is free to delete", inUse, target)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: the two sides still coordinate on an ordinary lease.
|
||||
func TestUnixSharedLeaseMakesCleanupReportInUse(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
held, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot acquire a runtime lease: %v", err)
|
||||
}
|
||||
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed against a legitimately held lease: %v", err)
|
||||
}
|
||||
if !inUse {
|
||||
t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using")
|
||||
}
|
||||
|
||||
held.release()
|
||||
lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed after the shared lease was released: %v", err)
|
||||
}
|
||||
if inUse {
|
||||
t.Fatal("cleanup still reported the root in use after the only holder released it, so reclamation never happens")
|
||||
}
|
||||
lease.release()
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runtimeRootUnderTest builds a root with the shape production uses, so the
|
||||
// components refuseAliasedRuntimeComponents walks stay inside test-owned
|
||||
// storage.
|
||||
//
|
||||
// Owned depth is the fixed names plus the digest, so a root placed directly in
|
||||
// t.TempDir() puts an owned component on /tmp, which the Unix ownership guard
|
||||
// correctly refuses because /tmp belongs to root. That is the guard working, not
|
||||
// a test environment problem, and it only shows up off Windows.
|
||||
func runtimeRootUnderTest(t *testing.T, leaf string) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", leaf)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// A LEASE IS OWNERSHIP FOR A TRANSACTION, NOT A SELECTION PROBE.
|
||||
//
|
||||
// Setup took a lease only to learn which root won and released it at once, so
|
||||
// nothing owned the selected root while the elevated helper provisioned the
|
||||
// tree, applied the ACL and stamp, installed network state and wrote the marker.
|
||||
// A command for another workspace scans the same runtime parent and excludes
|
||||
// only its own current root, so it can take this root's cleanup lease and
|
||||
// RemoveAll it mid-transaction; setup then publishes success for a pathname that
|
||||
// is gone.
|
||||
//
|
||||
// This pins the mechanism the fix relies on: a held lease is what makes the
|
||||
// cleanup's exclusive acquire fail.
|
||||
func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) {
|
||||
root := runtimeRootUnderTest(t, "deadbeefdeadbeef")
|
||||
|
||||
lease, err := prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire the runtime lease: %v", err)
|
||||
}
|
||||
removeSandboxRuntimeRootIfUnused(root)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Fatalf("cleanup removed a root that setup was holding: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
|
||||
// And once nothing holds it, cleanup does its job: without this half the test
|
||||
// would pass against a cleanup that never removes anything.
|
||||
removeSandboxRuntimeRootIfUnused(root)
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Fatalf("an unheld root survived cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The lease is on the root the caller names, so holding one root does not
|
||||
// protect a sibling: setup reserving its own selection must not stop the
|
||||
// cleanup doing its work elsewhere.
|
||||
func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) {
|
||||
held := runtimeRootUnderTest(t, "aaaaaaaaaaaaaaaa")
|
||||
sibling := filepath.Join(filepath.Dir(held), "bbbbbbbbbbbbbbbb")
|
||||
if err := os.MkdirAll(sibling, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lease, err := prepareSandboxRuntimeLease(held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lease.release()
|
||||
|
||||
removeSandboxRuntimeRootIfUnused(sibling)
|
||||
if _, err := os.Stat(sibling); !os.IsNotExist(err) {
|
||||
t.Errorf("holding one root blocked cleanup of an unrelated sibling: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(held); err != nil {
|
||||
t.Errorf("the held root was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// createdRuntimeDirsForTest builds identity-bound rollback records for paths a
|
||||
// test made itself, so a test can express "these are the directories this run
|
||||
// created" without restating the identity capture.
|
||||
func createdRuntimeDirsForTest(paths ...string) []windowsCreatedRuntimeDir {
|
||||
records := make([]windowsCreatedRuntimeDir, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
identity, identified := runtimeDirIdentity(path)
|
||||
records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity, identified: identified})
|
||||
}
|
||||
return records
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// acquireRuntimeLeaseForPlatform creates and locks the lease through the same
|
||||
// rooted no-follow boundary the Windows side uses.
|
||||
//
|
||||
// A PRE-CHECK IS NOT A BOUNDARY. This used to call refuseAliasedRuntimeComponents
|
||||
// and then os.MkdirAll plus a pathname lease open. The guard answers about an
|
||||
// ABSENT component by saying there is nothing to alias, which is exactly the
|
||||
// state a fresh fallback root is in, and both calls that followed resolve the
|
||||
// name again. A link planted in between was therefore created through and
|
||||
// written into, and the next check could only report it after the writes.
|
||||
//
|
||||
// The alias guard is gone from this path rather than kept alongside: retaining it
|
||||
// would suggest the two together are the protection, when the descent is the
|
||||
// protection and the guard is the thing that could not be one. Other callers
|
||||
// still use it where a pathname really is all there is.
|
||||
func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
return acquireRuntimeLeaseRootedUnix(root)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// acquireRuntimeLeaseForPlatform routes Windows through the rooted, no-follow
|
||||
// acquisition. See acquireRuntimeLeaseRooted for why the pathname walk it
|
||||
// replaces was unsafe.
|
||||
func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
return acquireRuntimeLeaseRooted(root)
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// seedRuntimeLeaseTree creates the owned tail and the lease the way a first run
|
||||
// does, then removes just the lease file so a test can put something else at that
|
||||
// name. The tree has to exist, or cleanup's descent has nothing to open and the
|
||||
// test would pass for the wrong reason.
|
||||
func seedRuntimeLeaseTree(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Skipf("cannot seed a runtime lease here: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
if err := os.Remove(leasePath); err != nil {
|
||||
t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err)
|
||||
}
|
||||
return leasePath
|
||||
}
|
||||
|
||||
// plantFileLink puts a FILE symbolic link at leasePath pointing at an ordinary
|
||||
// file, and returns the target.
|
||||
//
|
||||
// A file link, not a junction: a junction is a directory and FILE_NON_DIRECTORY_FILE
|
||||
// already refuses it, so a junction here would pass against the defect and prove
|
||||
// nothing. Creating one needs SeCreateSymbolicLinkPrivilege, which an ordinary
|
||||
// unelevated account does not hold, so this skips there and runs on CI.
|
||||
func plantFileLink(t *testing.T, leasePath string) string {
|
||||
t.Helper()
|
||||
target := filepath.Join(t.TempDir(), "target.lease")
|
||||
if err := os.WriteFile(target, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, leasePath); err != nil {
|
||||
t.Skipf("cannot create a file symbolic link here, which needs SeCreateSymbolicLinkPrivilege: %v", err)
|
||||
}
|
||||
// SETUP: it really is a reparse point, or nothing below is about links.
|
||||
info, err := os.Lstat(leasePath)
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("SETUP INVALID: %s is not a link (%v, %v)", leasePath, info, err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// assertTargetUnlocked proves nobody holds a lock on the link's target, which is
|
||||
// the object the defect made the two sides fight over.
|
||||
func assertTargetUnlocked(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
file, err := os.OpenFile(target, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("open the link target: %v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
var overlapped windows.Overlapped
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
|
||||
t.Fatalf("the link target is locked, so the refusal happened after the lease had already been taken on it: %v", err)
|
||||
}
|
||||
_ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped)
|
||||
}
|
||||
|
||||
// FILE_OPEN_REPARSE_POINT SAYS DO NOT FOLLOW, NOT REFUSE.
|
||||
//
|
||||
// The rooted opener passed that flag and FILE_NON_DIRECTORY_FILE and treated the
|
||||
// pair as a guarantee. It is not: the flag returns a handle to the LINK, and
|
||||
// FILE_NON_DIRECTORY_FILE excludes directories rather than non-directory reparse
|
||||
// objects. A file symbolic link at <digest>.lease was opened, wrapped and locked
|
||||
// as though it were the lease.
|
||||
//
|
||||
// That matters because cleanup opened the same name by pathname with no
|
||||
// no-follow flag at all, so it locked the link's TARGET. Two holders, two
|
||||
// objects, both calls succeeding, and cleanup free to RemoveAll a runtime root a
|
||||
// live command was using.
|
||||
func TestSharedLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
leasePath := seedRuntimeLeaseTree(t, root)
|
||||
target := plantFileLink(t, leasePath)
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("shared acquisition accepted a file link at the lease name, so it is holding a lock on an object cleanup does not see")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
assertTargetUnlocked(t, target)
|
||||
}
|
||||
|
||||
// And cleanup refuses the same object, so it cannot decide the root is free by
|
||||
// locking something else.
|
||||
func TestCleanupLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
leasePath := seedRuntimeLeaseTree(t, root)
|
||||
target := plantFileLink(t, leasePath)
|
||||
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("cleanup accepted a file link at the lease name (inUse=%t); it would treat a lock on %s as proof the runtime root is free to delete", inUse, target)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
assertTargetUnlocked(t, target)
|
||||
}
|
||||
|
||||
// CONTROL: THE TWO SIDES REALLY DO COORDINATE ON AN ORDINARY LEASE.
|
||||
//
|
||||
// Without this, refusing everything would satisfy the two tests above. This one
|
||||
// runs everywhere, including on an unelevated box with no symbolic-link
|
||||
// privilege, so the mutual-exclusion contract stays pinned even where the link
|
||||
// cases skip.
|
||||
func TestASharedLeaseMakesCleanupReportInUse(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
held, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Skipf("cannot acquire a runtime lease here: %v", err)
|
||||
}
|
||||
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed against a legitimately held lease: %v", err)
|
||||
}
|
||||
if !inUse {
|
||||
t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using")
|
||||
}
|
||||
|
||||
held.release()
|
||||
|
||||
lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed after the shared lease was released: %v", err)
|
||||
}
|
||||
if inUse {
|
||||
t.Fatal("cleanup still reported the runtime root in use after the only holder released it, so reclamation never happens")
|
||||
}
|
||||
if lease == nil {
|
||||
t.Fatal("cleanup reported the root free but handed back no lease")
|
||||
}
|
||||
lease.release()
|
||||
}
|
||||
|
||||
// CONTROL: a legitimate lease is still SHARED between processes.
|
||||
//
|
||||
// The classification must not turn the shared lease into an exclusive one, or
|
||||
// two concurrent commands on one workspace would stop working.
|
||||
func TestAnOrdinaryLeaseIsStillSharedByTwoHolders(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
first, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Skipf("cannot acquire a runtime lease here: %v", err)
|
||||
}
|
||||
defer first.release()
|
||||
|
||||
second, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("a second holder was refused an existing ordinary lease: %v", err)
|
||||
}
|
||||
second.release()
|
||||
}
|
||||
|
||||
// reparseGUIDDataBuffer is the non-Microsoft form of REPARSE_GUID_DATA_BUFFER.
|
||||
//
|
||||
// Setting a tag with the Microsoft bit clear needs only write access to the file,
|
||||
// unlike a symbolic link, so this runs on an ordinary unelevated account and the
|
||||
// classification stays pinned where the link cases skip.
|
||||
type reparseGUIDDataBuffer struct {
|
||||
ReparseTag uint32
|
||||
ReparseDataLength uint16
|
||||
Reserved uint16
|
||||
ReparseGUID windows.GUID
|
||||
Data [16]byte
|
||||
}
|
||||
|
||||
// plantGenericReparsePoint turns an ordinary file at path into a reparse object.
|
||||
func plantGenericReparsePoint(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := windows.CreateFile(name,
|
||||
windows.GENERIC_WRITE|windows.FILE_WRITE_ATTRIBUTES, 0, nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0)
|
||||
if err != nil {
|
||||
t.Skipf("cannot open %s to set a reparse tag: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
buffer := reparseGUIDDataBuffer{
|
||||
// Bit 31 clear, so this is a third-party tag and needs no privilege.
|
||||
ReparseTag: 0x00000042,
|
||||
ReparseDataLength: 16,
|
||||
ReparseGUID: windows.GUID{Data1: 0x5ee0e5f1, Data2: 0x1a11, Data3: 0x4d3b, Data4: [8]byte{0x9a, 0x77, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}},
|
||||
}
|
||||
size := uint32(unsafe.Sizeof(buffer))
|
||||
var returned uint32
|
||||
if err := windows.DeviceIoControl(handle, windowsFSCTLSetReparsePoint,
|
||||
(*byte)(unsafe.Pointer(&buffer)), size, nil, 0, &returned, nil); err != nil {
|
||||
t.Skipf("cannot set a generic reparse tag here: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const windowsFSCTLSetReparsePoint = 0x000900A4
|
||||
|
||||
// ANY REPARSE OBJECT AT THE LEASE NAME IS REFUSED, NOT ONLY A SYMBOLIC LINK.
|
||||
//
|
||||
// The requirement is that the name denotes an ordinary file, because that is what
|
||||
// makes the shared holder and cleanup lock the same thing. Keying on the link
|
||||
// shape instead would leave every other reparse tag accepted, and it would leave
|
||||
// this untested on any machine without the symbolic-link privilege.
|
||||
//
|
||||
// WHAT THIS CASE CANNOT SHOW, so nobody reads a local pass as more than it is:
|
||||
// an unknown third-party tag is unresolvable, so the OLD pathname cleanup fails
|
||||
// on it too, with ERROR_CANT_ACCESS_FILE rather than by classifying anything. It
|
||||
// therefore pins that both sites refuse, and the reason check below is what
|
||||
// separates a refusal from an accident. Only the symbolic-link cases above show
|
||||
// the half that matters most, a pathname open SUCCEEDING on the target, and those
|
||||
// need the privilege. Read their result in CI, not here.
|
||||
func TestLeaseRefusesAnyReparseObjectAtTheLeaseName(t *testing.T) {
|
||||
for name, acquire := range map[string]func(string) error{
|
||||
"shared": func(root string) error {
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
return err
|
||||
},
|
||||
"cleanup": func(root string) error {
|
||||
lease, _, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
return err
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
leasePath := seedRuntimeLeaseTree(t, root)
|
||||
plantGenericReparsePoint(t, leasePath)
|
||||
|
||||
// SETUP: the attribute really is set, or this asserts nothing.
|
||||
info, err := os.Lstat(leasePath)
|
||||
if err != nil || info.Mode()&os.ModeIrregular == 0 && info.Mode()&os.ModeSymlink == 0 {
|
||||
attrs, statErr := windowsFileAttributes(leasePath)
|
||||
if statErr != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT == 0 {
|
||||
t.Fatalf("SETUP INVALID: %s is not a reparse object (attrs=%#x err=%v)", leasePath, attrs, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
err = acquire(root)
|
||||
if err == nil {
|
||||
t.Fatalf("%s acquisition accepted a reparse object at the lease name", name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func windowsFileAttributes(path string) (uint32, error) {
|
||||
name, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return windows.GetFileAttributes(name)
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// acquireRuntimeLeaseRootedUnix creates and locks the lease for root without
|
||||
// resolving an owned component by name.
|
||||
//
|
||||
// The lease is the FIRST thing setup writes, so it is the write a redirection has
|
||||
// to be caught before, not after. It is a sibling of the runtime root rather than
|
||||
// one of its owned components, which is why the alias guard never looked at it at
|
||||
// all: it walks the components, and the lease is not one of them.
|
||||
//
|
||||
// Only the components above the leaf are created here. The leaf belongs to
|
||||
// provisioning, which records it for rollback, and creating it here would mean
|
||||
// two owners for one directory.
|
||||
func acquireRuntimeLeaseRootedUnix(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || len(components) == 0 {
|
||||
// Fail rather than fall back to the pathname walk: the walk is the defect.
|
||||
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
if runtimeLeasePreCreateBarrier != nil {
|
||||
runtimeLeasePreCreateBarrier()
|
||||
}
|
||||
// The base is the operator's, and may legitimately be a redirected or linked
|
||||
// temp location, so it is created and opened by name exactly as before.
|
||||
// Everything below it is Zero's and is addressed by descriptor.
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err)
|
||||
}
|
||||
|
||||
// The lease sits beside the leaf, so the deepest directory needed here is the
|
||||
// leaf's parent.
|
||||
tail := make([]string, 0, len(components)-1)
|
||||
current := base
|
||||
for _, component := range components[:len(components)-1] {
|
||||
current = filepath.Join(current, component)
|
||||
tail = append(tail, current)
|
||||
}
|
||||
|
||||
// THE WHOLE ACQUISITION RETRIES, NOT JUST THE LOCK. A contender that waited
|
||||
// on the lease while cleanup held it exclusively can wake on an object cleanup
|
||||
// has since unlinked, and by then cleanup has compensated the tail this run
|
||||
// created as well. Re-taking the lock alone would lock a fresh file beside a
|
||||
// leaf that is gone, so a stale lock sends the caller back through tail
|
||||
// creation. The ledger accumulates across attempts: a directory this run made
|
||||
// on an earlier pass and cleanup did not remove is still this run's to
|
||||
// compensate.
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for attempt := 1; ; attempt++ {
|
||||
made, parent, err := createRuntimeTailRetainingFD(base, tail)
|
||||
created = appendCreatedRuntimeDirs(created, made)
|
||||
if err != nil {
|
||||
return nil, created, err
|
||||
}
|
||||
handle, madeLease, err := acquireSharedRuntimeLeaseAtFD(parent, filepath.Base(sandboxRuntimeLeasePath(root)))
|
||||
_ = unix.Close(parent)
|
||||
if err == nil {
|
||||
return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil
|
||||
}
|
||||
if errors.Is(err, errRuntimeLeaseReplaced) && attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// acquireSharedRuntimeLeaseAtFD opens the lease relative to a verified parent and
|
||||
// takes the shared lock on it.
|
||||
func acquireSharedRuntimeLeaseAtFD(parent int, name string) (runtimeLeaseHandle, bool, error) {
|
||||
file, created, err := openRuntimeLeaseAtFD(parent, name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
|
||||
// The lock is the last step, and until now the created fact went out only
|
||||
// alongside a lease object. Failing here returned no object, so this
|
||||
// invocation's own lease file stayed on disk with nothing recording it,
|
||||
// and its directory could never be compensated afterwards.
|
||||
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// THE LOCK IS ON AN INODE, THE COORDINATION IS ON A NAME. Flock blocks while
|
||||
// cleanup holds the exclusive lock, and cleanup unlinks the lease name before
|
||||
// it lets go. A waiter then acquires the old, unlinked inode and returns a
|
||||
// lease that coordinates with nobody: the next cleanup creates a fresh file at
|
||||
// the name, locks it unopposed, and reports the root unused while this holder
|
||||
// is still running in it. So the lock is only a lease once the locked object
|
||||
// is still what the name resolves to. Reported by @gnanam1990.
|
||||
current, err := runtimeLeaseIsCurrentAtFD(parent, name, int(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
// Not undone by name, whatever created says: the name is no longer this
|
||||
// object's, so an unlink there would take somebody else's lease.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, created, nil
|
||||
}
|
||||
|
||||
// runtimeLeaseIsCurrentAtFD reports whether the locked lease is still the object
|
||||
// the lease name resolves to under parent.
|
||||
//
|
||||
// Both answers come from the descriptor side: the locked file's own identity and
|
||||
// the directory entry read relative to the retained parent, no-follow. A missing
|
||||
// entry, a different inode at the name, or a locked inode with no links left all
|
||||
// mean the coordination moved on while this call was waiting.
|
||||
func runtimeLeaseIsCurrentAtFD(parent int, name string, fd int) (bool, error) {
|
||||
var locked, entry unix.Stat_t
|
||||
if err := unix.Fstat(fd, &locked); err != nil {
|
||||
return false, fmt.Errorf("inspect the locked sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
if locked.Nlink == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := unix.Fstatat(parent, name, &entry, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
if errors.Is(err, unix.ENOENT) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
|
||||
}
|
||||
return locked.Dev == entry.Dev && locked.Ino == entry.Ino, nil
|
||||
}
|
||||
|
||||
// undoCreatedRuntimeLeaseAtFD removes a lease file this call created, when a
|
||||
// later step means no lease object will be returned to carry that fact.
|
||||
//
|
||||
// Relative to the verified parent descriptor, and only when this call is the one
|
||||
// that created the file: a lease that was already there belongs to whoever is
|
||||
// holding it.
|
||||
func undoCreatedRuntimeLeaseAtFD(created bool, parent int, name string, cause error) error {
|
||||
if !created {
|
||||
return cause
|
||||
}
|
||||
if undoErr := unix.Unlinkat(parent, name, 0); undoErr != nil {
|
||||
return fmt.Errorf("%w; and the lease file this run created could not be removed: %w", cause, undoErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// openRuntimeLeaseAtFD opens or creates the lease under parent and proves it is
|
||||
// an ordinary file.
|
||||
//
|
||||
// O_NOFOLLOW so a symlink planted at the lease name is an ELOOP rather than an
|
||||
// open of its target, and a regular-file check besides, because O_NOFOLLOW says
|
||||
// nothing about a fifo or a device a local user can also create there. Both
|
||||
// holders have to end up on the same object or the lock protects nothing.
|
||||
func openRuntimeLeaseAtFD(parent int, name string) (*os.File, bool, error) {
|
||||
const flags = unix.O_RDWR | unix.O_NOFOLLOW | unix.O_CLOEXEC
|
||||
// O_EXCL FIRST, so the create is what reports the create. A Stat beforehand
|
||||
// answers about a moment that has passed by the time the open runs, and
|
||||
// compensation would then delete a lease another process had just made.
|
||||
created := true
|
||||
fd, err := unix.Openat(parent, name, flags|unix.O_CREAT|unix.O_EXCL, 0o600)
|
||||
if errors.Is(err, unix.EEXIST) {
|
||||
created = false
|
||||
fd, err = unix.Openat(parent, name, flags, 0)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.ELOOP) {
|
||||
return nil, false, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is a link, so the holders would lock different objects: %w", name, err)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
// Every failure from here on has already created the file when created is
|
||||
// true, and returns no lease object to carry that fact, so each one undoes
|
||||
// its own creation rather than leaving an unrecorded artifact behind.
|
||||
if runtimeCreationFailure != nil {
|
||||
if injected := runtimeCreationFailure(name); injected != nil {
|
||||
injected = undoCreatedRuntimeLeaseAtFD(created, parent, name, injected)
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, injected
|
||||
}
|
||||
}
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is not an ordinary file (mode %#o)", name, stat.Mode&unix.S_IFMT))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) {
|
||||
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is owned by uid %d, not %d", name, stat.Uid, uid))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), name)
|
||||
if file == nil {
|
||||
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
return file, created, nil
|
||||
}
|
||||
|
||||
// tryAcquireExclusiveRuntimeLeaseRootedUnix is cleanup's acquisition, resolved
|
||||
// the way acquisition resolves it.
|
||||
//
|
||||
// It opens and never creates the tree: a runtime root that is not there has no
|
||||
// lease to take, and cleanup rebuilding it in order to lock it would be inventing
|
||||
// the thing it is about to remove.
|
||||
func tryAcquireExclusiveRuntimeLeaseRootedUnix(root string) (runtimeLeaseHandle, bool, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || len(components) == 0 {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
path := base
|
||||
for _, name := range components[:len(components)-1] {
|
||||
path = filepath.Join(path, name)
|
||||
child, openErr := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if openErr != nil {
|
||||
_ = unix.Close(parent)
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr)
|
||||
}
|
||||
if err := refuseForeignRuntimeDirectory(child, path); err != nil {
|
||||
_ = unix.Close(child)
|
||||
_ = unix.Close(parent)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
_ = unix.Close(parent)
|
||||
parent = child
|
||||
}
|
||||
defer func() { _ = unix.Close(parent) }()
|
||||
|
||||
name := filepath.Base(sandboxRuntimeLeasePath(root))
|
||||
for attempt := 1; ; attempt++ {
|
||||
file, created, err := openRuntimeLeaseAtFD(parent, name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
// Somebody holds it, so it is not this call's to remove, whatever the
|
||||
// open reported. A file this call created cannot reach here anyway.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// The exclusive side has the same window in miniature: between its open
|
||||
// and its lock another cleanup can unlink and replace the name, and an
|
||||
// exclusive lock on the old inode would then remove a tree a holder of the
|
||||
// new one is entering. Same check, same answer: only the current object
|
||||
// counts, and a stale one is reopened rather than reasoned about.
|
||||
current, err := runtimeLeaseIsCurrentAtFD(parent, name, int(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
_ = file.Close()
|
||||
if attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, false, nil
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// acquireRuntimeLeaseRooted creates and locks the lease file for root without
|
||||
// ever resolving an owned component by name.
|
||||
//
|
||||
// THE LEASE IS ACQUIRED BEFORE ANYTHING ELSE, SO IT CANNOT BE THE WEAK LINK.
|
||||
//
|
||||
// Provisioning already descends from the fixed cache or TEMP base through
|
||||
// retained no-follow handles, because a predictable owned component is exactly
|
||||
// what an ordinary same-account process can replace with a junction. Lease
|
||||
// acquisition ran first and did neither: it checked the components for aliases,
|
||||
// then called os.MkdirAll on the parent by pathname and opened
|
||||
// "<root>.lease" by pathname, both of which follow. A junction dropped on
|
||||
// "zero", "runtime" or "v1" between the check and either call put elevated
|
||||
// setup's first writes inside somebody else's tree, and restoring the component
|
||||
// afterwards left the later handle-relative provisioning working on the
|
||||
// legitimate tree so no post-check ever saw it.
|
||||
//
|
||||
// The lease file is a SIBLING of the runtime root rather than one of its owned
|
||||
// components, so refuseAliasedRuntimeComponents never inspected it at all. Here
|
||||
// it is created relative to the retained handle for the directory that contains
|
||||
// it, which is the deepest owned component, so its name is resolved exactly once
|
||||
// and relative to a verified object.
|
||||
//
|
||||
// Only the components above the leaf are created. The leaf itself belongs to
|
||||
// provisioning, which records it for rollback; creating it here would mean two
|
||||
// owners for one directory.
|
||||
func acquireRuntimeLeaseRooted(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned {
|
||||
// Fail rather than fall back to the pathname walk: the walk is the defect.
|
||||
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
if len(components) == 0 {
|
||||
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
// The base is the operator's, and may legitimately be a redirected cache or
|
||||
// TEMP location, so it is created and opened by name exactly as provisioning
|
||||
// does. Everything below it is Zero's and is addressed by handle.
|
||||
if runtimeLeasePreCreateBarrier != nil {
|
||||
runtimeLeasePreCreateBarrier()
|
||||
}
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err)
|
||||
}
|
||||
|
||||
// The lease sits beside the leaf, so the deepest directory needed here is the
|
||||
// leaf's parent.
|
||||
parents := components[:len(components)-1]
|
||||
tail := make([]string, 0, len(parents))
|
||||
current := base
|
||||
for _, component := range parents {
|
||||
current = filepath.Join(current, component)
|
||||
tail = append(tail, current)
|
||||
}
|
||||
|
||||
// THE WHOLE ACQUISITION RETRIES, NOT JUST THE LOCK. A contender that waited
|
||||
// on the lease while cleanup held it exclusively can wake on an object cleanup
|
||||
// has since marked for deletion, and by then cleanup has compensated the tail
|
||||
// this run created as well. Re-taking the lock alone would lock a fresh file
|
||||
// beside a leaf that is gone, so a stale lock sends the caller back through
|
||||
// tail creation. The ledger accumulates across attempts: a directory this run
|
||||
// made on an earlier pass and cleanup did not remove is still this run's to
|
||||
// compensate.
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for attempt := 1; ; attempt++ {
|
||||
made, parent, err := createRuntimeTailRetainingHandle(base, tail)
|
||||
created = appendCreatedRuntimeDirs(created, made)
|
||||
if err != nil {
|
||||
if parent != 0 {
|
||||
_ = windows.CloseHandle(parent)
|
||||
}
|
||||
return nil, created, err
|
||||
}
|
||||
handle, madeLease, err := acquireSharedRuntimeLeaseAt(parent, filepath.Base(sandboxRuntimeLeasePath(root)))
|
||||
_ = windows.CloseHandle(parent)
|
||||
if err == nil {
|
||||
return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil
|
||||
}
|
||||
if errors.Is(err, errRuntimeLeaseReplaced) && attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// acquireSharedRuntimeLeaseAt is acquireSharedRuntimeLease with the file named
|
||||
// relative to a directory handle instead of by full path.
|
||||
func acquireSharedRuntimeLeaseAt(parent windows.Handle, name string) (runtimeLeaseHandle, bool, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease 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 iosb windows.IO_STATUS_BLOCK
|
||||
// FILE_OPEN_IF because the lease is shared: whoever gets there first creates
|
||||
// it. FILE_NON_DIRECTORY_FILE so a directory under that name is refused.
|
||||
//
|
||||
// FILE_OPEN_REPARSE_POINT IS NOT A REFUSAL. It says do not follow, so the call
|
||||
// returns a handle to the LINK, and FILE_NON_DIRECTORY_FILE excludes
|
||||
// directories rather than non-directory reparse objects. A file symbolic link
|
||||
// planted at <digest>.lease was therefore opened and locked as if it were the
|
||||
// lease. No-follow and classification are two requirements; the flag is only
|
||||
// the first, and the handle is asked for the second below.
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// FILE_CREATED here, rather than a Stat before the call, because only the
|
||||
// create itself can distinguish the file it made from one that arrived a
|
||||
// moment earlier.
|
||||
created := iosb.Information == windowsFileCreatedDisposition
|
||||
// And every failure below has already created the file when that is true,
|
||||
// while returning no lease object to carry the fact, so each undoes its own
|
||||
// creation instead of leaving an unrecorded artifact in a directory
|
||||
// compensation then cannot empty.
|
||||
if runtimeCreationFailure != nil {
|
||||
if injected := runtimeCreationFailure(name); injected != nil {
|
||||
injected = undoCreatedRuntimeLease(created, handle, injected)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, injected
|
||||
}
|
||||
}
|
||||
if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil {
|
||||
err = undoCreatedRuntimeLease(created, handle, err)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), name)
|
||||
if file == nil {
|
||||
err := undoCreatedRuntimeLease(created, handle, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
lease := runtimeLeaseHandle{file: file}
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &lease.overlapped); err != nil {
|
||||
err = undoCreatedRuntimeLease(created, windows.Handle(file.Fd()), err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// THE LOCK IS ON AN OBJECT, THE COORDINATION IS ON A NAME. LockFileEx waits
|
||||
// while cleanup holds the exclusive lock, and cleanup marks the lease for
|
||||
// deletion before it lets go; this handle was opened with FILE_SHARE_DELETE
|
||||
// precisely so cleanup could. A waiter then locks a delete-pending object and
|
||||
// returns a lease that coordinates with nobody: once this handle closes the
|
||||
// name is free, the next cleanup creates a fresh file there, locks it
|
||||
// unopposed, and reports the root unused while this holder is still running
|
||||
// in it. So the lock is only a lease once the locked object is still what the
|
||||
// name resolves to. Reported by @gnanam1990.
|
||||
current, err := runtimeLeaseIsCurrentAt(parent, name, windows.Handle(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
// Not undone, whatever created says: the object is already going, and the
|
||||
// name now belongs to whoever comes next. Closing this handle is what lets
|
||||
// the name go so the retry can take a current one.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return lease, created, nil
|
||||
}
|
||||
|
||||
// runtimeLeaseIsCurrentAt reports whether the locked lease is still the object
|
||||
// the lease name resolves to under parent.
|
||||
//
|
||||
// Both answers come from handles: the locked file's own identity, and a fresh
|
||||
// no-follow open of the name relative to the retained parent, asked for nothing
|
||||
// but attributes. A name that is gone or delete-pending, or that resolves to a
|
||||
// different volume and file index, means the coordination moved on while this
|
||||
// call was waiting.
|
||||
func runtimeLeaseIsCurrentAt(parent windows.Handle, name string, locked windows.Handle) (bool, error) {
|
||||
var lockedInfo windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(locked, &lockedInfo); err != nil {
|
||||
return false, fmt.Errorf("inspect the locked sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("encode sandbox runtime lease 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 probe windows.Handle
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
err = windows.NtCreateFile(
|
||||
&probe,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) || errors.Is(err, windows.STATUS_DELETE_PENDING) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(probe) }()
|
||||
var entryInfo windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(probe, &entryInfo); err != nil {
|
||||
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
|
||||
}
|
||||
return lockedInfo.VolumeSerialNumber == entryInfo.VolumeSerialNumber &&
|
||||
lockedInfo.FileIndexHigh == entryInfo.FileIndexHigh &&
|
||||
lockedInfo.FileIndexLow == entryInfo.FileIndexLow, nil
|
||||
}
|
||||
|
||||
// undoCreatedRuntimeLease removes a lease file this call created, when a later
|
||||
// step means no lease object will be returned to carry that fact.
|
||||
//
|
||||
// Through the handle, and only when this call is the one that created the file:
|
||||
// a lease that was already there belongs to whoever is holding it.
|
||||
func undoCreatedRuntimeLease(created bool, handle windows.Handle, cause error) error {
|
||||
if !created {
|
||||
return cause
|
||||
}
|
||||
if undoErr := removeWindowsObjectByHandle(handle); undoErr != nil {
|
||||
return fmt.Errorf("%w; and the lease file this run created could not be removed: %w", cause, undoErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// refuseReparseRuntimeLeaseHandle proves the opened lease is an ordinary file.
|
||||
//
|
||||
// Asked of the HANDLE, not the name, so there is no second resolution for a
|
||||
// substitution to land in. This is the check the directory descent already makes
|
||||
// in openWindowsChildNoFollow; the lease carried the no-follow flag and not the
|
||||
// classification that has to go with it.
|
||||
func refuseReparseRuntimeLeaseHandle(handle windows.Handle, name string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to use the sandbox runtime lease at %s: a reparse point here means the shared and exclusive holders would lock different objects", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openRuntimeLeaseParentRooted opens, and never creates, the directory holding
|
||||
// the lease for root.
|
||||
//
|
||||
// Cleanup's side of the contract. It descends the same owned components from the
|
||||
// same base with the same no-follow opens acquisition uses, so both sides resolve
|
||||
// the lease name relative to a verified directory rather than by pathname.
|
||||
//
|
||||
// It creates nothing. A runtime tree that is not there has no lease to take, and
|
||||
// cleanup rebuilding the tree in order to lock it would be inventing the thing it
|
||||
// is about to remove.
|
||||
func openRuntimeLeaseParentRooted(root string) (windows.Handle, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || len(components) == 0 {
|
||||
return 0, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
parent, err := openWindowsDirectoryByName(base)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
// The lease is a SIBLING of the leaf, so the deepest directory needed here is
|
||||
// the leaf's parent, exactly as in acquisition.
|
||||
for _, name := range components[:len(components)-1] {
|
||||
child, openErr := openWindowsChildNoFollow(parent, name,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
|
||||
if openErr != nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
return 0, openErr
|
||||
}
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = child
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// tryAcquireExclusiveRuntimeLeaseRooted is cleanup's acquisition, resolved the
|
||||
// way acquisition resolves it.
|
||||
func tryAcquireExclusiveRuntimeLeaseRooted(root string) (runtimeLeaseHandle, bool, error) {
|
||||
parent, err := openRuntimeLeaseParentRooted(root)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
name := filepath.Base(sandboxRuntimeLeasePath(root))
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
var handle windows.Handle
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
// FILE_OPEN_IF matches what cleanup did by pathname with O_CREATE: a
|
||||
// runtime root whose lease file is gone is held by nobody, and creating the
|
||||
// empty lease is how that is expressed.
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
created := iosb.Information == windowsFileCreatedDisposition
|
||||
if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil {
|
||||
err = undoCreatedRuntimeLease(created, handle, err)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), name)
|
||||
if file == nil {
|
||||
err := undoCreatedRuntimeLease(created, handle, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
lease := runtimeLeaseHandle{file: file}
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &lease.overlapped); err != nil {
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
// Somebody holds it, so it is not this call's to remove, whatever the
|
||||
// open reported. A file this call created cannot reach here anyway.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
err = undoCreatedRuntimeLease(created, windows.Handle(file.Fd()), err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// The exclusive side has the same window in miniature: between its open
|
||||
// and its lock another cleanup can mark the object for deletion, and an
|
||||
// exclusive lock on that object would then remove a tree a holder of the
|
||||
// replacement is entering. Same check, same answer: only the current
|
||||
// object counts, and a stale one is reopened rather than reasoned about.
|
||||
current, err := runtimeLeaseIsCurrentAt(parent, name, windows.Handle(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
_ = file.Close()
|
||||
if attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return lease, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// windowsFileCreatedDisposition is the IO_STATUS_BLOCK Information value that
|
||||
// says NtCreateFile made the file rather than opening one that was there.
|
||||
// x/sys/windows does not export it.
|
||||
const windowsFileCreatedDisposition = 2
|
||||
@@ -3,6 +3,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -12,15 +13,31 @@ type runtimeLeaseHandle struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
// BOTH SIDES HAVE TO MEAN THE SAME OBJECT.
|
||||
//
|
||||
// Cleanup opened the lease by full pathname while acquisition opened it relative
|
||||
// to a verified parent, so a symlink at <digest>.lease gave the two of them
|
||||
// different files: a live command held a shared lock on one, cleanup took an
|
||||
// exclusive lock on the other, both succeeded, and cleanup went on to remove a
|
||||
// runtime root that was still in use.
|
||||
func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) {
|
||||
return tryAcquireExclusiveRuntimeLeaseRootedUnix(root)
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, nil
|
||||
}
|
||||
|
||||
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, false, nil
|
||||
}
|
||||
|
||||
func (lease runtimeLeaseHandle) release() {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A CONTENDER THAT WAITED OUT A CLEANUP HOLDS THE CURRENT LEASE, NOT THE OLD ONE.
|
||||
//
|
||||
// The schedule the exclusivity test does not reach. A command opens the lease
|
||||
// while cleanup holds it exclusively and waits for the shared lock. Cleanup
|
||||
// removes the lease name and the tree before it lets go. The waiter then wakes on
|
||||
// the old object: unlinked on POSIX, delete-pending on Windows. Locking it
|
||||
// succeeds, and the acquisition used to return that as a lease. Nothing else
|
||||
// could ever see it: the next cleanup created a fresh lease at the name, locked
|
||||
// it unopposed, and reported the root unused while the command was running in
|
||||
// it. The two locks each succeeded and coordinated nothing.
|
||||
//
|
||||
// Both seams are used as barriers, not as failures. The cleanup barrier is
|
||||
// where the contender is started, so its open lands while cleanup owns the lease;
|
||||
// the creation seam is installed there too, rather than up front, because
|
||||
// cleanup's own open consults it on POSIX and would otherwise signal before the
|
||||
// contender exists. Reported by @gnanam1990.
|
||||
func TestAContenderThatWaitedOutACleanupHoldsTheCurrentLease(t *testing.T) {
|
||||
root, _, leasePath := runtimeTreeAwaitingCompensation(t)
|
||||
// Only the leaf is this run's, the same shape as the exclusivity test: the
|
||||
// contender's retained parent handle must survive compensation, which is a
|
||||
// different race from the one under test.
|
||||
created := createdRuntimeDirsForTest(root)
|
||||
|
||||
opened := make(chan struct{})
|
||||
entered := make(chan struct{})
|
||||
var contender *sandboxRuntimeLease
|
||||
var contenderErr error
|
||||
runtimeCleanupExclusivityBarrier = func() {
|
||||
runtimeCreationFailure = func(string) error {
|
||||
select {
|
||||
case <-opened:
|
||||
default:
|
||||
close(opened)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
defer close(entered)
|
||||
contender, _, contenderErr = acquireRuntimeLeaseForPlatform(root)
|
||||
}()
|
||||
// The contender now holds a handle on the lease cleanup is about to
|
||||
// remove, and is waiting for the lock cleanup holds.
|
||||
<-opened
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runtimeCleanupExclusivityBarrier = nil
|
||||
runtimeCreationFailure = nil
|
||||
})
|
||||
|
||||
if err := undoWindowsSetupRuntimeCreation(root, created, leasePath, true); err != nil {
|
||||
t.Fatalf("compensation failed: %v", err)
|
||||
}
|
||||
<-entered
|
||||
runtimeCreationFailure = nil
|
||||
if contenderErr != nil {
|
||||
t.Fatalf("the contender could not take the lease after cleanup finished: %v", contenderErr)
|
||||
}
|
||||
if contender == nil {
|
||||
t.Fatal("SETUP INVALID: the contender returned neither a lease nor an error")
|
||||
}
|
||||
defer contender.release()
|
||||
|
||||
// The proof is the next cleanup: if the contender holds the current object,
|
||||
// cleanup finds the root in use. If it holds the object cleanup already
|
||||
// removed, cleanup locks a fresh one and calls the root free.
|
||||
second, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("a second cleanup could not take the lease at all, so the contender is holding an object the name no longer resolves to: %v", err)
|
||||
}
|
||||
if !inUse {
|
||||
second.release()
|
||||
t.Fatal("a second cleanup found the root unused while the contender holds a lease on it: the contender locked the object cleanup had already removed")
|
||||
}
|
||||
if _, statErr := os.Stat(leasePath); statErr != nil {
|
||||
t.Errorf("the contender's lease is not at the lease name: %v", statErr)
|
||||
}
|
||||
|
||||
// CONTROL: once the contender lets go, the root really is free.
|
||||
contender.release()
|
||||
third, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup after the contender released: %v", err)
|
||||
}
|
||||
if inUse {
|
||||
t.Fatal("the root is still reported in use after the contender released")
|
||||
}
|
||||
third.release()
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -13,19 +14,34 @@ type runtimeLeaseHandle struct {
|
||||
overlapped windows.Overlapped
|
||||
}
|
||||
|
||||
// BOTH SIDES HAVE TO MEAN THE SAME OBJECT.
|
||||
//
|
||||
// This opened the lease by full pathname with os.OpenFile and no no-follow flag,
|
||||
// while shared acquisition opened it relative to a retained parent handle. A file
|
||||
// symbolic link at <digest>.lease therefore handed the two sides different
|
||||
// objects: setup or a running command held a shared lock on the link, cleanup
|
||||
// took an exclusive lock on its target, both calls succeeded, and cleanup went on
|
||||
// to RemoveAll a root somebody was still using.
|
||||
//
|
||||
// Mutual exclusion is a property of the OBJECT, so cleanup resolves the name the
|
||||
// way acquisition does and refuses the same reparse objects.
|
||||
func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) {
|
||||
return tryAcquireExclusiveRuntimeLeaseRooted(root)
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
handle := runtimeLeaseHandle{file: file}
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
handle := runtimeLeaseHandle{file: file}
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
return handle, false, nil
|
||||
}
|
||||
|
||||
func (lease runtimeLeaseHandle) release() {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE SHAPE HAS TO HOLD FOR BOTH SPELLINGS OF THE FIRST COMPONENT.
|
||||
//
|
||||
// The cache-derived root uses the fixed name; the temp-derived fallback scopes
|
||||
// that component to the user wherever the temp root is shared. A tail that
|
||||
// stops matching loses the rooted no-follow traversal AND the owned-component
|
||||
// guard, and neither failure is visible where it happens.
|
||||
//
|
||||
// The scoped spelling does not exist on Windows, so without the seam this
|
||||
// branch could only ever be exercised by another platform's CI.
|
||||
func TestOwnedTailAcceptsBothFirstComponentSpellings(t *testing.T) {
|
||||
previous := fallbackOwnedNamesForMatch
|
||||
t.Cleanup(func() { fallbackOwnedNamesForMatch = previous })
|
||||
fallbackOwnedNamesForMatch = func() []string {
|
||||
return []string{windowsSandboxRuntimeOwnedNames[0] + "-u1001", "runtime", "v1"}
|
||||
}
|
||||
|
||||
base := filepath.Join("C:", "shared")
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
first string
|
||||
want bool
|
||||
}{
|
||||
{"fixed cache spelling", windowsSandboxRuntimeOwnedNames[0], true},
|
||||
{"user-scoped fallback spelling", windowsSandboxRuntimeOwnedNames[0] + "-u1001", true},
|
||||
{"a different user's scope", windowsSandboxRuntimeOwnedNames[0] + "-u2002", false},
|
||||
{"an unrelated directory", "notzero", false},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
root := filepath.Join(base, testCase.first, "runtime", "v1", "abcdef0123456789")
|
||||
if _, _, ok := windowsSandboxRuntimeOwnedTail(root); ok != testCase.want {
|
||||
t.Errorf("owned tail for %s = %v, want %v", root, ok, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The components below the first stay fixed for both spellings, or the
|
||||
// traversal would accept a tree Zero does not own.
|
||||
wrong := filepath.Join(base, windowsSandboxRuntimeOwnedNames[0]+"-u1001", "elsewhere", "v1", "abcdef0123456789")
|
||||
if _, _, ok := windowsSandboxRuntimeOwnedTail(wrong); ok {
|
||||
t.Errorf("owned tail accepted %s, whose middle component is not one Zero owns", wrong)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// physicalSandboxPath resolves path to the spelling the filesystem itself uses.
|
||||
//
|
||||
// Off Windows that is what canonicalSandboxWorkspaceRoot already does:
|
||||
// filepath.EvalSymlinks follows every symlink, and there is no junction to
|
||||
// follow. Two aliases remain unresolved and are handled by the identity walk in
|
||||
// runtimeRootWithinWorkspace instead: a differing case on a case-insensitive
|
||||
// volume, which EvalSymlinks preserves, and a bind mount, which no userspace
|
||||
// path API resolves because the kernel deliberately presents it as a real path.
|
||||
func physicalSandboxPath(path string) string {
|
||||
return canonicalSandboxWorkspaceRoot(path)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// GetFinalPathNameByHandle flags. golang.org/x/sys/windows does not export
|
||||
// these; both are zero, and naming them keeps the call site readable.
|
||||
fileNameNormalized = 0x0
|
||||
volumeNameDOS = 0x0
|
||||
)
|
||||
|
||||
// physicalSandboxPath resolves path to the spelling the filesystem itself uses.
|
||||
//
|
||||
// canonicalSandboxWorkspaceRoot cannot do this on Windows. filepath.EvalSymlinks
|
||||
// returns a directory JUNCTION unchanged, so a TEMP that reaches into the
|
||||
// workspace through one still measures as outside it, and that is how a runtime
|
||||
// tree ends up inside the very workspace the sandbox exists to confine.
|
||||
// GetFinalPathNameByHandle answers with the target's real path, junctions and
|
||||
// mount points followed and casing as stored on disk.
|
||||
//
|
||||
// This deliberately opens WITHOUT FILE_FLAG_OPEN_REPARSE_POINT, the opposite of
|
||||
// openWindowsACLTarget. That helper must refuse to follow a reparse point,
|
||||
// because following one is the path-swap it guards against. Here the whole
|
||||
// question is where the reparse point leads, and the answer is only ever used to
|
||||
// decide that a runtime root is contained, never that it is safe.
|
||||
func physicalSandboxPath(path string) string {
|
||||
cleaned := canonicalSandboxWorkspaceRoot(path)
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return cleaned
|
||||
}
|
||||
// The runtime root does not exist yet at derivation time, which is the point.
|
||||
// Resolve the deepest ancestor that does exist and re-append the rest, the
|
||||
// same shape canonicalSandboxWorkspaceRoot uses for EvalSymlinks.
|
||||
remainder := ""
|
||||
current := cleaned
|
||||
for {
|
||||
if resolved, ok := finalWindowsPathName(current); ok {
|
||||
if remainder == "" {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Join(resolved, remainder)
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
// Nothing along the path could be opened. The cleaned form is the best
|
||||
// answer available, and the caller's spelling comparison already ran.
|
||||
return cleaned
|
||||
}
|
||||
remainder = filepath.Join(filepath.Base(current), remainder)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func finalWindowsPathName(path string) (string, bool) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Zero desired access is enough: GetFinalPathNameByHandle reads metadata, so
|
||||
// this cannot be refused for lack of read rights on the directory contents.
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
0,
|
||||
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 "", false
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
buffer := make([]uint16, windows.MAX_PATH)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), fileNameNormalized|volumeNameDOS)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if int(n) >= len(buffer) {
|
||||
// TWO DIFFERENT CONVENTIONS, and the boundary is where they meet. On
|
||||
// success the return value EXCLUDES the terminating null; on an
|
||||
// insufficient buffer it INCLUDES it. So n == len(buffer) cannot be
|
||||
// read as a complete path: a success that large would not have fitted
|
||||
// its own terminator, which means the only reading left is a required
|
||||
// size. Retrying on >= costs one extra call in a case that may not be
|
||||
// reachable and removes the need to be right about which it was.
|
||||
buffer = make([]uint16, int(n)+1)
|
||||
continue
|
||||
}
|
||||
if n == 0 {
|
||||
return "", false
|
||||
}
|
||||
return trimWindowsExtendedPrefix(windows.UTF16ToString(buffer[:n])), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// trimWindowsExtendedPrefix converts the \\?\ form GetFinalPathNameByHandle
|
||||
// returns into an ordinary path, so it compares against paths the rest of this
|
||||
// package builds with filepath.Join. \\?\UNC\server\share becomes
|
||||
// \\server\share; anything else loses the \\?\ and keeps its drive letter.
|
||||
func trimWindowsExtendedPrefix(path string) string {
|
||||
if rest, ok := strings.CutPrefix(path, `\\?\UNC\`); ok {
|
||||
return `\\` + rest
|
||||
}
|
||||
return strings.TrimPrefix(path, `\\?\`)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE CREATION ITSELF HAS TO ESTABLISH THE IDENTITY.
|
||||
//
|
||||
// Win32 CreateFile cannot create a directory whatever disposition it is given,
|
||||
// so the CREATE_NEW attempt fell through to os.Mkdir plus a separate reopen on
|
||||
// EVERY real creation and the documented creation-handle contract never once
|
||||
// held. The runtime parent belongs to the ordinary user, who can rename the new
|
||||
// directory A away and drop an ordinary directory B at the predictable name in
|
||||
// between, and the ledger then records B for a directory this run never made.
|
||||
//
|
||||
// Driving the interleaving is not the point, and a barrier there would only
|
||||
// prove the window was still measurable. The property is that no reopen exists
|
||||
// to be raced: whatever the name resolves to afterwards, the recorded identity
|
||||
// is the object the create returned.
|
||||
func TestCreatedRuntimeDirIdentityComesFromTheCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "created")
|
||||
|
||||
// THE DETERMINISTIC BARRIER. The old shape resolved the pathname a second
|
||||
// time after os.Mkdir, and that reopen is the whole window. Counting the
|
||||
// resolutions turns "the creation establishes the identity" into something
|
||||
// checked: on Windows the create returns the handle, so this must be zero.
|
||||
resolutions := 0
|
||||
restore := runtimeIdentityAfterCreate
|
||||
runtimeIdentityAfterCreate = func(p string) (string, bool) {
|
||||
resolutions++
|
||||
return restore(p)
|
||||
}
|
||||
t.Cleanup(func() { runtimeIdentityAfterCreate = restore })
|
||||
|
||||
identity, identified, err := createRuntimeDirIdentified(path)
|
||||
if err != nil || !identified {
|
||||
t.Fatalf("create: identity=%q identified=%v err=%v", identity, identified, err)
|
||||
}
|
||||
if resolutions != 0 {
|
||||
t.Errorf("the creation resolved the pathname again %d time(s); identity must come from the creation handle", resolutions)
|
||||
}
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("no directory was created: err=%v", statErr)
|
||||
}
|
||||
|
||||
// Substitute the whole directory the way the parent's owner could, then ask
|
||||
// what the name says now. The record must still describe what was created.
|
||||
aside := filepath.Join(root, "moved-aside")
|
||||
if err := os.Rename(path, aside); err != nil {
|
||||
t.Skipf("cannot rename the runtime directory on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
substitute, ok := runtimeDirIdentity(path)
|
||||
if !ok {
|
||||
t.Fatal("the substitute could not be identified, so the comparison proves nothing")
|
||||
}
|
||||
if identity == substitute {
|
||||
t.Fatal("SETUP INVALID: the substitute has the same identity as the created directory")
|
||||
}
|
||||
moved, ok := runtimeDirIdentity(aside)
|
||||
if !ok {
|
||||
t.Fatal("the created directory could not be identified after the rename")
|
||||
}
|
||||
if identity != moved {
|
||||
t.Errorf("the recorded identity %q describes neither the created directory (%q) nor anything this run owns", identity, moved)
|
||||
}
|
||||
}
|
||||
|
||||
// A component another process created first must stay "not ours", and it must
|
||||
// keep saying so through os.IsExist, which is what the caller asks and which
|
||||
// does not unwrap a %w chain.
|
||||
func TestCreatedRuntimeDirRefusesAnExistingName(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "created")
|
||||
if _, _, err := createRuntimeDirIdentified(path); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
identity, identified, err := createRuntimeDirIdentified(path)
|
||||
if !os.IsExist(err) {
|
||||
t.Errorf("creating over an existing directory returned %v, want an IsExist error", err)
|
||||
}
|
||||
if identified || identity != "" {
|
||||
t.Errorf("a refused create still produced an ownership record: identity=%q identified=%v", identity, identified)
|
||||
}
|
||||
}
|
||||
|
||||
// THE THREE STATES ARE DIFFERENT FACTS.
|
||||
//
|
||||
// "Read it and there was nothing" and "could not read it" both used to arrive as
|
||||
// existed=false. The stamp writer uses FILE_OVERWRITE_IF and can replace an
|
||||
// existing stamp even where the read was denied, so that lie let a setup which
|
||||
// then FAILED delete an attestation it had no record of, leaving the previous
|
||||
// run's marker pointing at a runtime root it can no longer prove.
|
||||
func TestRuntimeStampSnapshotSeparatesAbsentPresentAndUnknown(t *testing.T) {
|
||||
t.Run("absent", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("a readable root with no stamp is not an error: %v", err)
|
||||
}
|
||||
if state != runtimeStampAbsent {
|
||||
t.Errorf("state = %v, want absent", state)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Errorf("absent produced prior bytes %q", prior)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("present", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
want := []byte("prior-attestation")
|
||||
if err := os.WriteFile(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), want, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if state != runtimeStampPresent {
|
||||
t.Errorf("state = %v, want present", state)
|
||||
}
|
||||
if string(prior) != string(want) {
|
||||
t.Errorf("prior = %q, want %q", prior, want)
|
||||
}
|
||||
})
|
||||
|
||||
// A stamp NAME that cannot be read as a file. The child open is refused for a
|
||||
// reason that is emphatically not "not found", which is the whole distinction.
|
||||
t.Run("unknown", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err == nil {
|
||||
t.Fatal("an unreadable stamp was reported as a successful snapshot")
|
||||
}
|
||||
if state != runtimeStampUnknown {
|
||||
t.Errorf("state = %v, want unknown", state)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Errorf("unknown produced prior bytes %q", prior)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AND UNKNOWN MUST NOT AUTHORIZE COMPENSATION.
|
||||
//
|
||||
// The forward mutation is refused before it begins, so this asserts the second
|
||||
// half: a record that somehow reached compensation with an unproven prior state
|
||||
// leaves the current stamp alone and says so, rather than deleting it and
|
||||
// returning with nothing to put back.
|
||||
func TestUnknownPriorStampIsNeverCompensated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stampPath := filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
current := []byte("the-attestation-of-the-previous-successful-setup")
|
||||
if err := os.WriteFile(stampPath, current, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, identified := runtimeDirIdentity(root)
|
||||
if !identified {
|
||||
t.Fatal("SETUP INVALID: the runtime root could not be identified")
|
||||
}
|
||||
|
||||
snapshot := windowsSandboxStampSnapshot{
|
||||
path: stampPath,
|
||||
priorState: runtimeStampUnknown,
|
||||
root: root,
|
||||
rootIdentity: identity,
|
||||
rootIdentified: true,
|
||||
}
|
||||
err := snapshot.restore()
|
||||
if err == nil {
|
||||
t.Fatal("compensation acted on a prior state it never established, and reported success")
|
||||
}
|
||||
|
||||
after, readErr := os.ReadFile(stampPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("the existing stamp was destroyed by a rollback that had nothing to restore: %v", readErr)
|
||||
}
|
||||
if string(after) != string(current) {
|
||||
t.Errorf("the existing stamp was rewritten: got %q, want %q", after, current)
|
||||
}
|
||||
}
|
||||
|
||||
// And setup refuses BEFORE the ACL and stamp are applied, which is the half that
|
||||
// keeps the writer from running at all.
|
||||
func TestSetupRefusesAnUnreadablePriorStamp(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err == nil {
|
||||
t.Fatal("setup accepted a snapshot that could not read the prior stamp")
|
||||
}
|
||||
if snapshot.priorState != runtimeStampUnknown {
|
||||
t.Errorf("the refused snapshot carried state %v, want unknown", snapshot.priorState)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A RECORDED ROOT IS HISTORY. IT MUST NOT BE RE-DERIVED FROM TODAY'S TEMP.
|
||||
//
|
||||
// Setup reaches the temp fallback when the preferred cache-derived root cannot
|
||||
// be leased, and it records that concrete root in the marker. Command selection
|
||||
// then recognised the record only by deriving today's fallback from this
|
||||
// process's TEMP and comparing. A later IDE, service or terminal running with a
|
||||
// different TEMP therefore matched neither of today's candidates: the record was
|
||||
// ignored, selection produced a tree setup never provisioned, and the runner
|
||||
// rejected the marker as out of date. Re-running setup from the original
|
||||
// environment repeats the original answer and does not make the other
|
||||
// environment converge.
|
||||
//
|
||||
// The record is now recognised by its own shape instead, which is the question
|
||||
// that was actually being asked: does this belong to this workspace and home.
|
||||
//
|
||||
// Not gated on Windows. The marker only exists where setup wrote one, so this is
|
||||
// Windows-only in practice, but keeping it platform-neutral means the
|
||||
// setup-to-command contract runs on every CI runner rather than only one.
|
||||
func TestARecordedFallbackSurvivesATempChange(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
// The cache is an input of the preferred candidate, so it is owned as well.
|
||||
// Left alone it made the result depend on the machine, and gave this test two
|
||||
// more ways to skip instead of asserting.
|
||||
cacheRoot := t.TempDir()
|
||||
originalCacheDir := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir })
|
||||
|
||||
// EVERY temp variable, in both phases. With only TMP and TEMP set this
|
||||
// changed nothing on Unix: the fallback derived from the ambient TMPDIR, the
|
||||
// MkdirAll below created a workspace-digest tree outside anything this test
|
||||
// owns, the second derivation came back identical, and the test skipped
|
||||
// having asserted nothing and left the directory behind.
|
||||
tempA := t.TempDir()
|
||||
redirectSandboxTestTemp(t, tempA)
|
||||
recorded, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no fallback runtime root under a temp this test owns: %v", err)
|
||||
}
|
||||
// Containment BEFORE the tree exists, so cleanup belongs to tempA and happens
|
||||
// even when an assertion below fails.
|
||||
requireWithinTestOwned(t, recorded, tempA)
|
||||
if err := os.MkdirAll(recorded, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRecordedRoot(t, home, recorded)
|
||||
|
||||
// SETUP: with the original TEMP the record is honoured, so a failure after the
|
||||
// flip is about the flip and not about the record being unusable.
|
||||
preferred := preferredRuntimeRootFor(t, workspace)
|
||||
if got := pinnedSandboxRuntimeRoot(workspace, preferred, recorded, home); got != recorded {
|
||||
t.Fatalf("SETUP INVALID: the record is not honoured even before the temp change: got %q, want %q", got, recorded)
|
||||
}
|
||||
|
||||
// The same command, run later from an environment with a different TEMP.
|
||||
tempB := t.TempDir()
|
||||
redirectSandboxTestTemp(t, tempB)
|
||||
derivedNow, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no fallback runtime root under the second temp: %v", err)
|
||||
}
|
||||
requireWithinTestOwned(t, derivedNow, tempB)
|
||||
// A FAILURE, NOT A SKIP. The redirect owns every input the derivation reads,
|
||||
// so an unmoved fallback means the premise of this test is broken, and a skip
|
||||
// here is how it passed on Unix without ever reaching the assertion below.
|
||||
if sameWindowsRuntimeRootPath(derivedNow, recorded) {
|
||||
t.Fatalf("SETUP INVALID: the temp change did not move the derived fallback (%s), so the assertion below would prove nothing", derivedNow)
|
||||
}
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(workspace, preferred, derivedNow, home); got != recorded {
|
||||
t.Fatalf("selection abandoned the root setup provisioned after only TEMP changed: got %q, want the recorded %q", got, recorded)
|
||||
}
|
||||
}
|
||||
|
||||
// preferredRuntimeRootFor is the cache-derived candidate, which must stay a
|
||||
// candidate: this change is about the fallback only.
|
||||
func preferredRuntimeRootFor(t *testing.T, workspace string) string {
|
||||
t.Helper()
|
||||
cacheRoot, err := sandboxUserCacheDir()
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no user cache directory: %v", err)
|
||||
}
|
||||
root, err := sandboxRuntimeRootFor(workspace, canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no preferred runtime root under a cache this test owns: %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// AND THE PROTECTION THAT MADE THE OLD COMPARISON WORTH HAVING STAYS.
|
||||
//
|
||||
// Recognising the record by shape must not turn into recognising anybody's
|
||||
// record. A fallback root belonging to a DIFFERENT workspace has the same owned
|
||||
// shape and a different digest, so it is still refused.
|
||||
func TestARecordedFallbackForAnotherWorkspaceIsStillRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
mine := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
theirs := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
|
||||
temp := t.TempDir()
|
||||
redirectSandboxTestTemp(t, temp)
|
||||
|
||||
theirRoot, err := fallbackSandboxRuntimeRoot(theirs)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no fallback runtime root under a temp this test owns: %v", err)
|
||||
}
|
||||
requireWithinTestOwned(t, theirRoot, temp)
|
||||
writeRecordedRoot(t, home, theirRoot)
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(mine, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
|
||||
t.Fatalf("pinned %q, which was provisioned for another workspace", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A record with no owned runtime shape at all is refused too, so the shape test
|
||||
// is doing work rather than waving anything through.
|
||||
func TestARecordedRootWithoutTheOwnedShapeIsRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "not-a-runtime-root"))
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(workspace, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
|
||||
t.Fatalf("pinned %q, which has none of the owned runtime shape", got)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EXISTENCE MUST NOT CHOOSE THE TRUST BOUNDARY.
|
||||
//
|
||||
// createRuntimeDirRecording used to os.Stat its way to the deepest component
|
||||
// that already existed and open THAT by name. On the ordinary second-workspace
|
||||
// shape <base>\zero\runtime\v1 is already there and only the digest is missing,
|
||||
// so the single by-name open was v1 — one of the predictable, user-owned
|
||||
// components the rooted traversal exists to protect, not the cache directory
|
||||
// above the tail.
|
||||
//
|
||||
// The race that opens: the pre-check accepts an ordinary v1, os.Stat selects it,
|
||||
// the local owner replaces it with a junction, the by-name open follows it, and
|
||||
// elevated setup creates the digest beneath the redirected target. Putting the
|
||||
// original back makes the post-check see an ordinary pathname again, so the
|
||||
// creation record names the redirected object under the original path and
|
||||
// compensation leaves a privileged creation as residue.
|
||||
//
|
||||
// This drives the PRODUCTION entry point rather than the descent helper, because
|
||||
// the helper is handed a base that has already been chosen — which is precisely
|
||||
// the decision under test. A junction needs no privilege on Windows, so this
|
||||
// runs unelevated.
|
||||
func TestRuntimeRecordingOpensTheFixedBaseNotTheDeepestExistingComponent(t *testing.T) {
|
||||
cacheBase := t.TempDir()
|
||||
target := t.TempDir()
|
||||
|
||||
// The shape a second workspace finds: everything above the digest already
|
||||
// exists, so the old walk would have picked v1 as its by-name base.
|
||||
v1 := filepath.Join(cacheBase, "zero", "runtime", "v1")
|
||||
if err := os.MkdirAll(v1, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := filepath.Join(v1, "deadbeefdeadbeef")
|
||||
|
||||
// Sanity: the helper agrees the fixed base is the cache directory, not v1.
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || base != cacheBase {
|
||||
t.Fatalf("SETUP INVALID: owned tail resolved base=%q owned=%v, want %q", base, owned, cacheBase)
|
||||
}
|
||||
if len(components) == 0 {
|
||||
t.Fatal("SETUP INVALID: the owned tail has no components")
|
||||
}
|
||||
|
||||
// THE PROPERTY, OBSERVED DIRECTLY. Whether a particular swap is caught depends
|
||||
// on where the barrier sits; which path is opened by name does not, and that is
|
||||
// the finding. Recorded here so the assertion cannot pass for an unrelated
|
||||
// reason.
|
||||
var openedByName []string
|
||||
previousOpen := runtimeBaseOpenedByName
|
||||
runtimeBaseOpenedByName = func(path string) { openedByName = append(openedByName, path) }
|
||||
t.Cleanup(func() { runtimeBaseOpenedByName = previousOpen })
|
||||
|
||||
swapped := false
|
||||
previous := runtimeDescentBarrier
|
||||
runtimeDescentBarrier = func() {
|
||||
// Fires after the base has been opened and before the first owned
|
||||
// component is touched: exactly the interval the old walk was vulnerable
|
||||
// in. Replace v1 with a junction into a directory the test watches.
|
||||
if err := os.Remove(v1); err != nil {
|
||||
t.Fatalf("SETUP INVALID: could not clear v1 to plant the junction: %v", err)
|
||||
}
|
||||
out, err := exec.Command("cmd", "/c", "mklink", "/J", v1, target).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
swapped = true
|
||||
}
|
||||
t.Cleanup(func() { runtimeDescentBarrier = previous })
|
||||
|
||||
created, err := createRuntimeDirRecording(root)
|
||||
|
||||
if !swapped {
|
||||
t.Fatal("SETUP INVALID: the barrier never ran, so no swap was attempted")
|
||||
}
|
||||
// Exactly one by-name open, and it is the cache directory above the tail.
|
||||
// Under the old walk this was v1, a component the local user controls.
|
||||
if len(openedByName) != 1 {
|
||||
t.Fatalf("the descent opened %d paths by name, want exactly 1: %v", len(openedByName), openedByName)
|
||||
}
|
||||
if !strings.EqualFold(openedByName[0], cacheBase) {
|
||||
t.Fatalf("opened %q by name, want the fixed base %q: existence must not choose the trust boundary", openedByName[0], cacheBase)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("the recording walked through a junction at an owned component and reported success: created=%v", created)
|
||||
}
|
||||
// The redirected target must be untouched: no digest created beneath it.
|
||||
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.Errorf("elevated setup created %v beneath the redirected target", names)
|
||||
}
|
||||
// And nothing may be recorded as ours under a pathname that now names
|
||||
// somebody else's object, since that is what compensation acts on.
|
||||
for _, record := range created {
|
||||
if strings.HasPrefix(strings.ToLower(record.path), strings.ToLower(v1)) {
|
||||
t.Errorf("a redirected creation was recorded as ours: %+v", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// aliasTo returns a second path that IS target, spelled in a way
|
||||
// canonicalSandboxWorkspaceRoot does not fold, or "" when this platform offers
|
||||
// none.
|
||||
//
|
||||
// A plain symlink is no good: EvalSymlinks folds it, so the spelling comparison
|
||||
// already wins and nothing downstream is exercised. The two aliases that survive
|
||||
// canonicalization are a Windows directory junction, which needs no privilege,
|
||||
// and an upper-cased spelling on a case-insensitive volume, which is the macOS
|
||||
// default.
|
||||
func aliasTo(t *testing.T, target string) string {
|
||||
t.Helper()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
link := filepath.Join(t.TempDir(), "alias")
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
|
||||
t.Logf("mklink /J unavailable: %v %s", err, out)
|
||||
return ""
|
||||
}
|
||||
// A junction that did not actually land on the target proves nothing.
|
||||
targetInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("stat target: %v", err)
|
||||
}
|
||||
linkInfo, err := os.Stat(link)
|
||||
if err != nil || !os.SameFile(targetInfo, linkInfo) {
|
||||
t.Fatalf("mklink reported success but %s is not %s", link, target)
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(target)
|
||||
if upper == target {
|
||||
return ""
|
||||
}
|
||||
targetInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("stat target: %v", err)
|
||||
}
|
||||
upperInfo, err := os.Stat(upper)
|
||||
if err != nil || !os.SameFile(targetInfo, upperInfo) {
|
||||
// Case-sensitive volume: the upper-cased name is a different directory, or
|
||||
// none at all. Correctly not an alias.
|
||||
return ""
|
||||
}
|
||||
return upper
|
||||
}
|
||||
|
||||
// TestRuntimeRootRefusesAWorkspaceReachedByAnAlias is the regression for the gap
|
||||
// the macOS Smoke run exposed and the junction gap found alongside it.
|
||||
// canonicalSandboxWorkspaceRoot folds the aliases EvalSymlinks folds and no
|
||||
// others, so a runtime root that reaches the workspace under a junction, or under
|
||||
// a different casing on a case-insensitive volume, measured as OUTSIDE the
|
||||
// workspace and the runtime tree was allowed to live inside the tree the sandbox
|
||||
// exists to confine.
|
||||
//
|
||||
// Both alias shapes are covered on purpose. An alias whose target IS the
|
||||
// workspace root is caught by the identity walk; an alias into a SUBDIRECTORY is
|
||||
// not, because the walk climbs a spelling and a junction has no spelling chain
|
||||
// back into its target's parent. Only the physical-path resolution catches that
|
||||
// one, and a test that exercised the root shape alone reported green while it was
|
||||
// broken.
|
||||
func TestRuntimeRootRefusesAWorkspaceReachedByAnAlias(t *testing.T) {
|
||||
for _, shape := range []struct {
|
||||
name string
|
||||
suffix []string
|
||||
}{
|
||||
{name: "alias to the workspace root"},
|
||||
{name: "alias into a workspace subdirectory", suffix: []string{"build", "tmp"}},
|
||||
} {
|
||||
t.Run(shape.name, func(t *testing.T) {
|
||||
workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace"))
|
||||
target := filepath.Join(append([]string{workspaceRoot}, shape.suffix...)...)
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
alias := aliasTo(t, target)
|
||||
if alias == "" {
|
||||
t.Skip("no alias spelling is constructible here")
|
||||
}
|
||||
|
||||
// Precondition, asserted rather than assumed: the plain spelling
|
||||
// comparison has to MISS. If canonicalization folds this alias the old
|
||||
// code already handled it and the test proves nothing.
|
||||
probe := filepath.Join(canonicalSandboxWorkspaceRoot(alias), "zero", "runtime", "v1", "0123456789abcdef")
|
||||
if pathWithinRoot(workspaceRoot, probe) {
|
||||
t.Skipf("canonicalization already folds %s into %s", alias, workspaceRoot)
|
||||
}
|
||||
|
||||
t.Setenv("TMP", alias)
|
||||
t.Setenv("TEMP", alias)
|
||||
t.Setenv("TMPDIR", alias)
|
||||
|
||||
root, err := fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err == nil {
|
||||
t.Fatalf("fallback returned runtime root %s for a TEMP that reaches %s through %s; the sandbox would keep its own cache inside the tree it is confining", root, target, alias)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "inside workspace") {
|
||||
t.Fatalf("fallback refused for the wrong reason: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeterministicRuntimeRootRejectsAnAliasedCache covers the other call site.
|
||||
// Reverting only deterministicSandboxRuntimeRoot left the whole package green
|
||||
// before this existed, so the cache-derived root had no alias coverage at all.
|
||||
func TestDeterministicRuntimeRootRejectsAnAliasedCache(t *testing.T) {
|
||||
workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace"))
|
||||
inner := filepath.Join(workspaceRoot, "cachehome")
|
||||
if err := os.MkdirAll(inner, 0o700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
// The derived root is <cache>/zero/runtime/v1/<hash>, so aliasing <cache>/zero
|
||||
// is what puts the whole tree inside the workspace.
|
||||
cacheRoot := filepath.Join(t.TempDir(), "cache")
|
||||
if err := os.MkdirAll(cacheRoot, 0o700); err != nil {
|
||||
t.Fatalf("create cache: %v", err)
|
||||
}
|
||||
// A junction on Windows, a symlink elsewhere. Both point at inner by its REAL
|
||||
// spelling on purpose. Stacking a case alias on top would build the one shape
|
||||
// the doc on runtimeRootWithinWorkspace says stays open off Windows, an alias
|
||||
// into a workspace SUBDIRECTORY that no spelling chain reaches, and the test
|
||||
// would then be asserting a guarantee macOS does not make.
|
||||
link := filepath.Join(cacheRoot, "zero")
|
||||
if runtime.GOOS == "windows" {
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, inner).CombinedOutput(); err != nil {
|
||||
t.Skipf("mklink /J unavailable: %v %s", err, out)
|
||||
}
|
||||
} else if err := os.Symlink(inner, link); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
innerInfo, err := os.Stat(inner)
|
||||
if err != nil {
|
||||
t.Fatalf("stat target: %v", err)
|
||||
}
|
||||
linkInfo, err := os.Stat(link)
|
||||
if err != nil || !os.SameFile(innerInfo, linkInfo) {
|
||||
t.Fatalf("%s is not an alias of %s", link, inner)
|
||||
}
|
||||
|
||||
root, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot)
|
||||
if usableOutside {
|
||||
t.Fatalf("cache-derived runtime root %s reported as outside %s while it resolves inside it", root, workspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable is the other
|
||||
// half. The three checks only ever ADD containment answers, so the one thing that
|
||||
// could go wrong is reporting containment for a root that is merely adjacent.
|
||||
func TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(parent, "workspace"))
|
||||
sibling := filepath.Join(parent, "workspace-runtime", "zero", "runtime", "v1", "0123456789abcdef")
|
||||
if err := os.MkdirAll(workspaceRoot, 0o700); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(sibling, 0o700); err != nil {
|
||||
t.Fatalf("create sibling: %v", err)
|
||||
}
|
||||
|
||||
if runtimeRootWithinWorkspace(workspaceRoot, sibling) {
|
||||
t.Fatalf("%s reported as inside %s; a sibling sharing a name prefix is not contained", sibling, workspaceRoot)
|
||||
}
|
||||
if _, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, filepath.Join(parent, "cache")); !usableOutside {
|
||||
t.Fatalf("a cache root outside the workspace was reported unusable")
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// errRuntimeComponentAliased marks a refusal that must NOT be treated as a
|
||||
// reason to relocate.
|
||||
//
|
||||
// selectSandboxRuntimeRoot falls back to the temp root when the preferred one
|
||||
// cannot be leased, which is right for an unusable directory and wrong for a
|
||||
// hostile one: relocating would leave the attacker's link in place, say nothing,
|
||||
// and simply move to the next predictable name for them to take as well. A
|
||||
// machine in this state needs an operator, not a retry.
|
||||
var errRuntimeComponentAliased = errors.New("sandbox runtime component is aliased")
|
||||
|
||||
// THE RUNTIME TREE IS CREATED IN A DIRECTORY OTHER PEOPLE CAN WRITE TO.
|
||||
//
|
||||
// The fallback root used to come from os.MkdirTemp, which mints a random name
|
||||
// atomically at mode 0700, so no other local user could name the directory, let
|
||||
// alone pre-create it. Deriving it from a digest of the workspace path bought
|
||||
// stable cache reuse across runs and gave that name away: every component is
|
||||
// computable by anyone who can guess the workspace path, and on Linux
|
||||
// os.TempDir() is the shared, world-writable /tmp whenever TMPDIR is unset.
|
||||
//
|
||||
// What follows from a name another user can create is not subtle.
|
||||
// os.MkdirAll returns nil when Stat says the path is already a directory, and
|
||||
// Stat FOLLOWS LINKS, so a link planted at the leaf is silently accepted; the
|
||||
// cache, data and tmp directories are then created inside whatever it points at,
|
||||
// os.Chmod and os.Chtimes follow it too, and the root is handed to the platform
|
||||
// backend as a WRITE ROOT (a read-write bind under bwrap) with TMPDIR, GOCACHE,
|
||||
// GOMODCACHE and the package-manager caches all pointed inside it. The sandbox
|
||||
// would be granting the confined command write access to a directory an
|
||||
// attacker chose.
|
||||
//
|
||||
// Two things close it, and both are needed. The path carries a per-user
|
||||
// component so ordinary users are not sharing one tree, and every component Zero
|
||||
// owns is verified to be a real directory belonging to this user before anything
|
||||
// is created through it. The name alone is not enough: /tmp is world-writable,
|
||||
// so another user can create the per-user directory FIRST and wait.
|
||||
//
|
||||
// This is the same rule refuseReparsedRuntimeAncestors applies during elevated
|
||||
// Windows setup. That guard was never on this path, which is the shared one
|
||||
// every platform takes for every command.
|
||||
func refuseAliasedRuntimeComponents(root string) error {
|
||||
for _, component := range ownedRuntimeComponents(root) {
|
||||
info, err := os.Lstat(component)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Not there yet, so there is nothing to alias. The caller re-checks
|
||||
// after creation, because this alone is a check-then-use.
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("inspect sandbox runtime component %s: %w", component, err)
|
||||
}
|
||||
// ModeIrregular as well as ModeSymlink: a Windows junction is reported as
|
||||
// irregular, needs no privilege to create, and a guard written against
|
||||
// symlinks alone is inert against it.
|
||||
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
|
||||
return fmt.Errorf("%w: refusing to use the sandbox runtime through a link at %s: "+
|
||||
"a link here redirects the directory the sandbox is granted write access to", errRuntimeComponentAliased, component)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
// NOT tagged as aliased, deliberately. An ordinary file sitting where a
|
||||
// runtime component belongs is a broken machine rather than a hostile
|
||||
// one, and relocating to the other candidate is the sane recovery that
|
||||
// was already there. Only a link or a directory belonging to somebody
|
||||
// else says an attacker chose this path, and those are the two the
|
||||
// caller must refuse outright rather than route around.
|
||||
return fmt.Errorf("sandbox runtime component %s exists and is not a directory", component)
|
||||
}
|
||||
if err := refuseForeignRuntimeComponent(component, info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownedRuntimeComponents lists the trailing components Zero creates, deepest
|
||||
// first. Anything above them belongs to the user or the machine.
|
||||
func ownedRuntimeComponents(root string) []string {
|
||||
cleaned := filepath.Clean(root)
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return nil
|
||||
}
|
||||
components := make([]string, 0, windowsSandboxRuntimeOwnedDepth)
|
||||
current := cleaned
|
||||
for range windowsSandboxRuntimeOwnedDepth {
|
||||
components = append(components, current)
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return components
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// digestFor recomputes the fallback leaf name the way fallbackSandboxRuntimeRoot
|
||||
// does, so the test moves with the implementation rather than pinning a literal.
|
||||
func digestFor(workspaceRoot string, scope string) string {
|
||||
digest := sha256.Sum256([]byte(canonicalSandboxWorkspaceRoot(workspaceRoot) + "\x00" + scope))
|
||||
return hex.EncodeToString(digest[:8])
|
||||
}
|
||||
|
||||
var _ = testing.Verbose
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A POSIX symlink is the reachable alias off Windows.
|
||||
func linkRuntimeComponent(t *testing.T, link, target string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("cannot create a symlink in this environment: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A JUNCTION, not a symlink: it needs no privilege, which is what makes it the
|
||||
// alias an ordinary local user can actually plant, and os.Lstat reports it as
|
||||
// ModeIrregular rather than ModeSymlink.
|
||||
func linkRuntimeComponent(t *testing.T, link, target string) {
|
||||
t.Helper()
|
||||
output, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("cannot create a junction in this environment: %v (%s)", err, output)
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LINK AT AN OWNED COMPONENT MUST NOT BE BUILT THROUGH.
|
||||
//
|
||||
// This is the shared path every platform takes for every command, and until now
|
||||
// it had no guard at all: the only link refusal in this package was wired into
|
||||
// elevated Windows setup. os.MkdirAll returns nil when Stat says the path is
|
||||
// already a directory, and Stat FOLLOWS links, so a link planted at an owned
|
||||
// component is accepted silently. The cache, data and tmp directories are then
|
||||
// created inside whatever it points at, Chmod and Chtimes follow it too, and the
|
||||
// root is handed to the backend as a WRITE ROOT with TMPDIR, GOCACHE and the
|
||||
// package-manager caches pointed inside it. The sandbox would be granting the
|
||||
// confined command write access to a directory somebody else chose.
|
||||
func TestTheRuntimeGuardRefusesALinkAtEveryOwnedComponent(t *testing.T) {
|
||||
for depth := range windowsSandboxRuntimeOwnedDepth {
|
||||
t.Run("replaced "+string(rune('0'+depth))+" levels above the leaf", func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime tree: %v", err)
|
||||
}
|
||||
|
||||
swapped := root
|
||||
for range depth {
|
||||
swapped = filepath.Dir(swapped)
|
||||
}
|
||||
tail, err := filepath.Rel(swapped, root)
|
||||
if err != nil {
|
||||
t.Fatalf("relate the swapped component to the root: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(swapped); err != nil {
|
||||
t.Fatalf("clear the component to replace: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "somewhere-else")
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
t.Fatalf("create the link target: %v", err)
|
||||
}
|
||||
linkRuntimeComponent(t, swapped, target)
|
||||
if tail != "." {
|
||||
if err := os.MkdirAll(filepath.Join(target, tail), 0o700); err != nil {
|
||||
t.Fatalf("recreate the components below the link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = refuseAliasedRuntimeComponents(root)
|
||||
if err == nil {
|
||||
t.Fatalf("a link at an owned component was accepted; the runtime tree would have been built inside %s and granted to the sandbox as a write root", target)
|
||||
}
|
||||
// ASSERTED ON THE SENTINEL, not on a word in the message.
|
||||
//
|
||||
// This read strings.Contains(err.Error(), "link") and was vacuous:
|
||||
// t.TempDir() names its directory after the test, the subtest was called
|
||||
// "link N levels above the leaf", and every component path therefore
|
||||
// contained "link". Deleting the reparse refusal left a different error
|
||||
// ("exists and is not a directory") whose PATH still satisfied the
|
||||
// assertion, so all four subtests passed with the fix removed.
|
||||
//
|
||||
// The sentinel is also the real contract: selectSandboxRuntimeRoot
|
||||
// branches on errors.Is to decide refuse-versus-relocate.
|
||||
if !errors.Is(err, errRuntimeComponentAliased) {
|
||||
t.Errorf("a link was not reported as a hostile alias, so selection would relocate around it instead of refusing: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "redirects the directory") {
|
||||
t.Errorf("the refusal does not name the reason: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary tree passes, or the guard above would be satisfied by one that
|
||||
// refuses everything.
|
||||
func TestTheRuntimeGuardAcceptsAnOrdinaryTree(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime tree: %v", err)
|
||||
}
|
||||
if err := refuseAliasedRuntimeComponents(root); err != nil {
|
||||
t.Fatalf("an ordinary runtime tree was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A tree that does not exist yet is fine: there is nothing to alias, and this is
|
||||
// the ordinary first-run case.
|
||||
func TestTheRuntimeGuardAcceptsATreeThatDoesNotExistYet(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := refuseAliasedRuntimeComponents(root); err != nil {
|
||||
t.Fatalf("an absent runtime tree was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A FILE where an owned component belongs is refused too, rather than producing
|
||||
// a confusing failure deeper in.
|
||||
func TestTheRuntimeGuardRefusesAFileWhereADirectoryBelongs(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
t.Fatalf("create the runtime parents: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(root, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatalf("seed the file: %v", err)
|
||||
}
|
||||
if err := refuseAliasedRuntimeComponents(root); err == nil {
|
||||
t.Fatal("a file standing where the runtime root belongs was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// THE SHARED-TEMP FALLBACK IS SCOPED TO THIS USER.
|
||||
//
|
||||
// It replaced os.MkdirTemp, which minted a random 0700 directory atomically, so
|
||||
// no other local user could name it. A digest of the workspace path alone is the
|
||||
// same string for every account on the host, and on Linux os.TempDir() is the
|
||||
// world-writable /tmp whenever TMPDIR is unset: two users on the same path would
|
||||
// name one directory and the first one there would own it.
|
||||
func TestTheTempFallbackRootIsScopedToTheUser(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace))
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
scope := sandboxRuntimeUserScope()
|
||||
if strings.TrimSpace(scope) == "" {
|
||||
t.Fatal("the user scope is empty, so the digest is the same for every account")
|
||||
}
|
||||
|
||||
// The leaf must move when the scope does. Recomputed the way the function
|
||||
// does, rather than asserting on a hardcoded digest.
|
||||
same := digestFor(workspace, scope)
|
||||
other := digestFor(workspace, scope+"-someone-else")
|
||||
if same == other {
|
||||
t.Fatal("the user scope does not reach the digest")
|
||||
}
|
||||
if !strings.HasSuffix(filepath.Clean(root), same) {
|
||||
t.Errorf("the fallback root %s does not end in the user-scoped digest %s", root, same)
|
||||
}
|
||||
}
|
||||
|
||||
// And the fallback still has the shape the owned-component guard and the Windows
|
||||
// rooted traversal both recognize. A path that stops matching silently loses
|
||||
// BOTH protections, which is the expensive direction.
|
||||
func TestTheTempFallbackRootKeepsTheOwnedShape(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace))
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
if _, _, ok := windowsSandboxRuntimeOwnedTail(root); !ok {
|
||||
t.Fatalf("the fallback root %s is not recognized as an owned runtime tail, so the rooted traversal falls back to opening it by name", root)
|
||||
}
|
||||
components := ownedRuntimeComponents(root)
|
||||
if len(components) != windowsSandboxRuntimeOwnedDepth {
|
||||
t.Fatalf("the guard walks %d components of %s, want %d", len(components), root, windowsSandboxRuntimeOwnedDepth)
|
||||
}
|
||||
}
|
||||
|
||||
// THROUGH prepareSandboxRuntime, not the helper.
|
||||
//
|
||||
// The helper being correct proves nothing on its own: the guard has to be on the
|
||||
// path runner.go actually calls, before the MkdirAll that would build the tree
|
||||
// through the link and before the root is handed back as a write root.
|
||||
func TestPreparingTheRuntimeRefusesALinkedRoot(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
canonical := canonicalSandboxWorkspaceRoot(workspace)
|
||||
root, err := sandboxRuntimeRootFor(canonical, canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if err != nil {
|
||||
t.Skipf("no cache-derived runtime root in this environment: %v", err)
|
||||
}
|
||||
|
||||
// Somebody else got to the predictable name first and pointed it elsewhere.
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
t.Fatalf("create the runtime parents: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "somewhere-else")
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
t.Fatalf("create the link target: %v", err)
|
||||
}
|
||||
linkRuntimeComponent(t, root, target)
|
||||
|
||||
runtimeState, cleanup, err := prepareSandboxRuntime(canonical, "")
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("the runtime was prepared at %s through a link; %s would have been bound read-write into the sandbox with TMPDIR and the build caches inside it", runtimeState.Root, target)
|
||||
}
|
||||
if !errors.Is(err, errRuntimeComponentAliased) {
|
||||
t.Errorf("the linked root was not reported as a hostile alias: %v", err)
|
||||
}
|
||||
for _, name := range []string{"cache", "data", "tmp"} {
|
||||
if _, statErr := os.Stat(filepath.Join(target, name)); statErr == nil {
|
||||
t.Errorf("the runtime tree was created inside the link target at %s", filepath.Join(target, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// refuseForeignRuntimeComponent rejects a component owned by somebody else.
|
||||
//
|
||||
// The link check above stops the redirection; this stops the quieter half. /tmp
|
||||
// is world-writable and sticky, so another local user can create the components
|
||||
// Zero owns BEFORE Zero ever runs. A directory they own but Zero writes into is
|
||||
// a place they can read the sandbox's caches out of, and the sticky bit does not
|
||||
// help because they own it. os.MkdirAll accepts it silently, since the path
|
||||
// already exists as a directory.
|
||||
//
|
||||
// Ownership rather than mode, because a 0777 directory belonging to this user is
|
||||
// the user's own business while a 0700 directory belonging to another user is
|
||||
// not something Zero should adopt.
|
||||
func refuseForeignRuntimeComponent(component string, info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
// No ownership information on this filesystem. The link check already ran.
|
||||
return nil
|
||||
}
|
||||
if uid := os.Getuid(); uid >= 0 && int(stat.Uid) != uid {
|
||||
return fmt.Errorf("%w: refusing to use the sandbox runtime directory %s: it belongs to uid %d, not to this user (uid %d)",
|
||||
errRuntimeComponentAliased, component, stat.Uid, uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sandboxRuntimeUserScope isolates the derived runtime tree per user.
|
||||
//
|
||||
// Two users on one host derive the same digest for the same workspace path, so
|
||||
// without this they name one directory in shared temp and the first one there
|
||||
// owns it. A uid is not a secret and is not meant to be: it removes the
|
||||
// collision, and refuseAliasedRuntimeComponents handles the case where somebody
|
||||
// got there first.
|
||||
func sandboxRuntimeUserScope() string {
|
||||
return fmt.Sprintf("u%d", os.Getuid())
|
||||
}
|
||||
|
||||
// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime
|
||||
// root is built from.
|
||||
//
|
||||
// THE USER BOUNDARY COMES FIRST, ABOVE EVERY PRIVATE COMPONENT. On Unix
|
||||
// os.TempDir() is a SHARED directory whenever TMPDIR is unset, and runtime
|
||||
// preparation creates and ownership-checks each of these components at 0700. A
|
||||
// fixed first component therefore meant the first account to use the fallback
|
||||
// created a private directory that every other account was then refused at:
|
||||
// traversal fails on the mode, and relaxing the mode fails the ownership guard
|
||||
// instead. The per-workspace digest is the leaf, so it never got the chance to
|
||||
// separate them, and the fallback became first-user-wins on a shared host.
|
||||
//
|
||||
// Scoping the FIRST component keeps every ownership-checked ancestor inside a
|
||||
// namespace that already belongs to one user, which is the property the guards
|
||||
// below assume. The workspace digest stays the leaf so setup and the command
|
||||
// still derive the same path for the same workspace.
|
||||
func sandboxRuntimeFallbackOwnedNames() []string {
|
||||
names := append([]string(nil), windowsSandboxRuntimeOwnedNames...)
|
||||
names[0] = names[0] + "-" + sandboxRuntimeUserScope()
|
||||
return names
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// refuseForeignRuntimeComponent has no ownership check on Windows.
|
||||
//
|
||||
// The derived root lives under the per-user cache directory or the per-session
|
||||
// TEMP, both of which are already user-private, and the elevated setup path
|
||||
// applies its own capability ACL. The link refusal in the shared guard is the
|
||||
// part that matters here.
|
||||
func refuseForeignRuntimeComponent(string, os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sandboxRuntimeUserScope names the account the tree belongs to.
|
||||
//
|
||||
// Windows TEMP is already per-user, so this is belt and braces rather than the
|
||||
// load-bearing separation it is on Unix. Kept so the derived path has the same
|
||||
// shape on every platform and one code path builds it.
|
||||
//
|
||||
// FROM THE TOKEN, NOT THE ENVIRONMENT. Setup derives the fallback root in the
|
||||
// elevated terminal and records it; a command later re-derives it in an
|
||||
// ordinary one to decide whether that record belongs to this workspace. Those
|
||||
// are two processes with independent environments, so a USERNAME that differed
|
||||
// between them made the command reject the root setup had provisioned and
|
||||
// select one that carries no capability ACE. The token's user SID is the same
|
||||
// in both, because setup refuses to run as any account other than the one that
|
||||
// will use Zero. The Unix build reads the uid for the same reason. Reported by
|
||||
// CodeRabbit.
|
||||
func sandboxRuntimeUserScope() string {
|
||||
sid, err := currentProcessSID()
|
||||
if err != nil {
|
||||
return "u"
|
||||
}
|
||||
return "u" + strings.ToLower(sid)
|
||||
}
|
||||
|
||||
// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime
|
||||
// root is built from. Unscoped on Windows: os.TempDir() already resolves inside
|
||||
// the user's own profile, so the shared-temp collision the Unix build guards
|
||||
// against cannot arise, and the fixed names keep windowsSandboxRuntimeOwnedTail
|
||||
// able to recognise a root built by an elevated setup running as another
|
||||
// account.
|
||||
func sandboxRuntimeFallbackOwnedNames() []string {
|
||||
return windowsSandboxRuntimeOwnedNames
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A RECORDED ROOT IS HISTORY, NOT PROOF THAT A COMMAND WOULD STILL SELECT IT.
|
||||
//
|
||||
// Doctor pins the marker's runtime root so it can check the stamp without
|
||||
// taking a lease. A command does not pin blindly: it derives the current cache
|
||||
// and fallback candidates and honours the marker only when its root is one of
|
||||
// them. Run setup with the cache at A, relocate the cache so commands derive B,
|
||||
// and the stamped A tree stays behind. Pinning A reported a healthy machine
|
||||
// immediately before every real command rejected A and failed on the marker.
|
||||
//
|
||||
// This records a marker with the cache resolver pointed at A, flips the
|
||||
// resolver to B, and asks the same question a command asks. The seam is the
|
||||
// production resolver, so the derivation under test is the command's own.
|
||||
func TestRecordedRuntimeRootIsNotCurrentOnceTheCacheMoves(t *testing.T) {
|
||||
cacheA := t.TempDir()
|
||||
cacheB := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheA, nil }
|
||||
|
||||
config := WindowsSandboxSetupConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
}
|
||||
// The marker records profile.Runtime.Root, which setup sets from the root
|
||||
// it SELECTS. Select it here the way a command under cache A would, and
|
||||
// put it on the profile the way doctor does, so the recorded root is the
|
||||
// genuine A-derived one rather than a value this test invented.
|
||||
rootA, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheA))
|
||||
if err != nil {
|
||||
t.Fatalf("derive the runtime root under cache A: %v", err)
|
||||
}
|
||||
config.PermissionProfile = PermissionProfileWithRuntimeRoot(
|
||||
WindowsSandboxProfileWithRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots),
|
||||
rootA,
|
||||
)
|
||||
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
recorded, current, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("with the cache still at A: %v", err)
|
||||
}
|
||||
if recorded == "" {
|
||||
t.Fatal("SETUP INVALID: the marker recorded no runtime root, so the comparison proves nothing")
|
||||
}
|
||||
if !current {
|
||||
t.Fatalf("the root setup just recorded (%s) is reported stale while the cache has not moved", recorded)
|
||||
}
|
||||
|
||||
// The cache moves. Commands now derive under B; the stamped tree is under A.
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheB, nil }
|
||||
|
||||
recordedAfter, currentAfter, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("with the cache at B: %v", err)
|
||||
}
|
||||
if recordedAfter != recorded {
|
||||
t.Errorf("the recorded root changed on read: %q then %q", recorded, recordedAfter)
|
||||
}
|
||||
if currentAfter {
|
||||
t.Fatalf("the marker's root %s is under cache A, commands derive under B, and it was still reported current", recorded)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE DIAGNOSTIC REFUSES WHAT SELECTION REFUSES, WITH THE SAME WORDS.
|
||||
//
|
||||
// os.UserCacheDir can succeed and still hand back a value that canonicalizes to
|
||||
// nothing: an empty string, ".", or only whitespace. Selection fails closed on
|
||||
// that with "user cache directory is unavailable". The diagnostic that answers
|
||||
// "would a command still select the recorded root?" spelled the same steps out
|
||||
// separately and did not have that check, so it derived candidates from the
|
||||
// empty root and returned an answer, current or stale, on a machine where every
|
||||
// command fails before it reads a marker. Doctor then reported staleness, or
|
||||
// nothing at all, instead of runtime-root-unresolved.
|
||||
//
|
||||
// Both now get their inputs from one resolver. This drives the two production
|
||||
// entry points with the same stubbed cache value and requires the same error
|
||||
// from each. The seam is the production resolver's own. Reported by @jatmn.
|
||||
func TestTheDiagnosticRefusesTheCacheRootSelectionRefuses(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
usableCache := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return usableCache, nil }
|
||||
|
||||
config := WindowsSandboxSetupConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
}
|
||||
root, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(usableCache))
|
||||
if err != nil {
|
||||
t.Fatalf("derive the runtime root: %v", err)
|
||||
}
|
||||
config.PermissionProfile = PermissionProfileWithRuntimeRoot(
|
||||
WindowsSandboxProfileWithRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots),
|
||||
root,
|
||||
)
|
||||
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
// Healthy first: with a usable cache the diagnostic answers, and says
|
||||
// current. Without this the legs below could pass on a marker that was
|
||||
// never readable.
|
||||
if recorded, current, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace); err != nil || recorded == "" || !current {
|
||||
t.Fatalf("SETUP INVALID: with a usable cache the diagnostic gave (%q, %v, %v)", recorded, current, err)
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
cache string
|
||||
}{
|
||||
{name: "empty", cache: ""},
|
||||
{name: "dot", cache: "."},
|
||||
{name: "whitespace", cache: " "},
|
||||
{name: "dot with whitespace", cache: " . "},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
sandboxUserCacheDir = func() (string, error) { return testCase.cache, nil }
|
||||
|
||||
_, lease, _, selectErr := selectSandboxRuntimeRoot(workspace, true, config.SandboxHome)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if selectErr == nil {
|
||||
t.Fatalf("SETUP INVALID: selection accepted the cache root %q, so there is no refusal to match", testCase.cache)
|
||||
}
|
||||
|
||||
recorded, current, diagnosticErr := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
|
||||
if diagnosticErr == nil {
|
||||
t.Fatalf("the diagnostic answered current=%v for a cache root %q that selection refuses with %q; doctor would report that instead of runtime-root-unresolved", current, testCase.cache, selectErr)
|
||||
}
|
||||
if diagnosticErr.Error() != selectErr.Error() {
|
||||
t.Errorf("the diagnostic failed with %q and selection with %q; an operator is told two different things about one cause", diagnosticErr, selectErr)
|
||||
}
|
||||
if !strings.Contains(diagnosticErr.Error(), "user cache directory is unavailable") {
|
||||
t.Errorf("the cause does not name the cache directory: %q", diagnosticErr)
|
||||
}
|
||||
if current {
|
||||
t.Errorf("an unresolvable selection was still reported current")
|
||||
}
|
||||
if recorded == "" {
|
||||
t.Errorf("the recorded root was dropped from the answer, so doctor cannot say which marker it was looking at")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// And a resolver error is still reported as itself, by both.
|
||||
boom := errors.New("no cache location is defined")
|
||||
sandboxUserCacheDir = func() (string, error) { return "", boom }
|
||||
_, _, _, selectErr := selectSandboxRuntimeRoot(workspace, true, config.SandboxHome)
|
||||
_, _, diagnosticErr := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
|
||||
if !errors.Is(selectErr, boom) || !errors.Is(diagnosticErr, boom) || selectErr.Error() != diagnosticErr.Error() {
|
||||
t.Errorf("a resolver error reads differently: selection %q, diagnostic %q", selectErr, diagnosticErr)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// snapshotRuntimeStampBound keeps the pathname form off Windows. The split it
|
||||
// closes there is specific to an elevated installer racing an unelevated
|
||||
// renamer; the same eager identity capture still applies.
|
||||
//
|
||||
// The three-state result is NOT Windows-specific, though: "read it and there was
|
||||
// nothing" and "could not read it" are different facts on every platform, and
|
||||
// only the first may authorize a compensating delete. A permission or I/O error
|
||||
// here stops setup rather than being recorded as proven absence.
|
||||
func snapshotRuntimeStampBound(root string, name string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) {
|
||||
identity, identified = runtimeDirIdentity(root)
|
||||
path := filepath.Join(root, name)
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, fs.ErrNotExist) {
|
||||
return identity, identified, nil, runtimeStampAbsent, nil
|
||||
}
|
||||
return identity, identified, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp at %s: %w", path, readErr)
|
||||
}
|
||||
return identity, identified, data, runtimeStampPresent, nil
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// snapshotRuntimeStampBound reads the runtime root's identity and its existing
|
||||
// stamp through ONE handle.
|
||||
//
|
||||
// The two used to be taken separately: runtimeDirIdentity opened the root, read
|
||||
// its volume and file ID, and closed the handle, and then os.ReadFile resolved
|
||||
// the pathname again. A rename and substitution in that interval pairs A's
|
||||
// identity with B's stamp bytes, and a rollback that correctly proves it holds A
|
||||
// then writes B's bytes into it, corrupting an attestation that predates this
|
||||
// run. The lease stops cleanup selecting the root; it does not stop the parent's
|
||||
// owner renaming it.
|
||||
//
|
||||
// Reading the child relative to the identified handle removes the second
|
||||
// resolution, so both facts describe the same object by construction.
|
||||
//
|
||||
// EVERY FAILURE IS AN ERROR, NOT AN ABSENCE. This used to collapse an encoding
|
||||
// failure, a directory that would not open, an identity that could not be read,
|
||||
// a denied child open and a short read all into the same "there was no stamp"
|
||||
// answer that a genuine ERROR_FILE_NOT_FOUND produces. The stamp writer uses
|
||||
// FILE_OVERWRITE_IF and can replace an existing stamp even where the read was
|
||||
// denied, so that lie let a FAILED setup delete an attestation it had no record
|
||||
// of, leaving the previous run's marker pointing at an unusable runtime root.
|
||||
// Only a positive not-found produces runtimeStampAbsent.
|
||||
func snapshotRuntimeStampBound(root string, name string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) {
|
||||
utf16Root, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
return "", false, nil, runtimeStampUnknown, fmt.Errorf("encode sandbox runtime root %s: %w", root, err)
|
||||
}
|
||||
directory, err := windows.CreateFile(
|
||||
utf16Root,
|
||||
windows.FILE_TRAVERSE|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|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
// A root that is simply not there yet is the ordinary first-run case: the
|
||||
// created-directory rollback owns it and there is no prior stamp to lose.
|
||||
if isWindowsNotFound(err) {
|
||||
return "", false, nil, runtimeStampAbsent, nil
|
||||
}
|
||||
return "", false, nil, runtimeStampUnknown, fmt.Errorf("open sandbox runtime root %s: %w", root, err)
|
||||
}
|
||||
defer windows.CloseHandle(directory)
|
||||
|
||||
identity, idErr := handleRuntimeIdentity(directory)
|
||||
if idErr != nil {
|
||||
return "", false, nil, runtimeStampUnknown, fmt.Errorf("identify sandbox runtime root %s: %w", root, idErr)
|
||||
}
|
||||
|
||||
stamp, err := openWindowsChildNoFollow(directory, name,
|
||||
windows.GENERIC_READ|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE)
|
||||
if err != nil {
|
||||
if isWindowsNotFound(err) {
|
||||
// Proven absent. The identity still stands: it came from the handle
|
||||
// above, not from this.
|
||||
return identity, true, nil, runtimeStampAbsent, nil
|
||||
}
|
||||
return identity, true, nil, runtimeStampUnknown, fmt.Errorf("open the sandbox runtime stamp in %s: %w", root, err)
|
||||
}
|
||||
file := os.NewFile(uintptr(stamp), name)
|
||||
defer file.Close()
|
||||
data, readErr := io.ReadAll(file)
|
||||
if readErr != nil {
|
||||
return identity, true, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp in %s: %w", root, readErr)
|
||||
}
|
||||
return identity, true, data, runtimeStampPresent, nil
|
||||
}
|
||||
|
||||
// isWindowsNotFound reports the two statuses that mean the object genuinely is
|
||||
// not there, as opposed to the many that mean it could not be looked at.
|
||||
//
|
||||
// openWindowsChildNoFollow wraps its NTSTATUS, and CreateFile returns the Win32
|
||||
// errno, so both spellings are checked rather than assuming one layer.
|
||||
func isWindowsNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, os.ErrNotExist) ||
|
||||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
|
||||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND) ||
|
||||
errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
|
||||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
|
||||
}
|
||||
@@ -21,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"`
|
||||
@@ -28,106 +33,37 @@ 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
|
||||
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
||||
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
|
||||
if workspaceRoot == "" || workspaceRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
|
||||
}
|
||||
cacheRoot, err := sandboxUserCacheDir()
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
|
||||
}
|
||||
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
|
||||
if cacheRoot == "" || cacheRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
|
||||
}
|
||||
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))
|
||||
// The same inventory the rooted traversal recognizes a runtime root by. See
|
||||
// windowsSandboxRuntimeOwnedNames: two spellings of this list is how a real
|
||||
// runtime root stops being recognized as owned, and that failure opens by
|
||||
// name instead of by handle.
|
||||
root := filepath.Join(append(append([]string{cacheRoot}, windowsSandboxRuntimeOwnedNames...), hex.EncodeToString(digest[:8]))...)
|
||||
return root, !runtimeRootWithinWorkspace(workspaceRoot, root)
|
||||
}
|
||||
|
||||
// runtimeRootWithinWorkspace reports whether root lands inside workspaceRoot.
|
||||
//
|
||||
// pathWithinRoot compares SPELLINGS, and canonicalSandboxWorkspaceRoot folds only
|
||||
// the aliases filepath.EvalSymlinks folds. Two get through it. Case: filepath.Rel
|
||||
// folds case on Windows via sameWord but not elsewhere, so on a case-insensitive
|
||||
// macOS volume /var/folders/x and /VAR/FOLDERS/X are one directory that every
|
||||
// string comparison here keeps apart. Junctions: EvalSymlinks returns a Windows
|
||||
// directory junction unchanged, so a TEMP that reaches the workspace through one
|
||||
// measures as outside it.
|
||||
//
|
||||
// Three checks, each of which can only ADD a containment answer. That asymmetry
|
||||
// is the safety argument: the failure that matters is the runtime tree living
|
||||
// inside the workspace, so a missed alias is the expensive direction and an extra
|
||||
// relocation is the cheap one.
|
||||
//
|
||||
// 1. the spellings as given;
|
||||
// 2. the spellings resolved to physical paths, which on Windows follows
|
||||
// junctions at any depth via GetFinalPathNameByHandle;
|
||||
// 3. filesystem identity across root's existing ancestors, which catches a case
|
||||
// alias on a case-insensitive volume where step 2 has no API to call.
|
||||
//
|
||||
// Step 3 only sees an alias whose target IS the workspace root, because it walks
|
||||
// a SPELLING upward and a junction has no spelling chain back into its target's
|
||||
// parent. That shape is covered by step 2 on Windows. It remains open off Windows
|
||||
// for a bind mount, which the kernel presents as a real path with no way to ask
|
||||
// where it came from; closing that needs mountinfo parsing, not a path API.
|
||||
func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool {
|
||||
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
return true
|
||||
}
|
||||
if physicalWorkspace := physicalSandboxPath(workspaceRoot); physicalWorkspace != "" {
|
||||
if pathWithinRoot(physicalWorkspace, physicalSandboxPath(root)) {
|
||||
return true
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
}
|
||||
workspaceInfo, err := os.Stat(workspaceRoot)
|
||||
lease, err := prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
// An unresolvable workspace leaves nothing to compare against. The
|
||||
// spelling checks above already returned their answer.
|
||||
return false
|
||||
}
|
||||
// root itself usually does not exist yet, which is the point: start at the
|
||||
// deepest component and walk up, so the first directory that does exist gets
|
||||
// compared and every ancestor above it after that.
|
||||
current := filepath.Clean(root)
|
||||
for {
|
||||
if info, err := os.Stat(current); err == nil && os.SameFile(workspaceInfo, info) {
|
||||
return true
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return false
|
||||
lease, err = prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRuntime, func(), error) {
|
||||
// One selection function, shared with setup. See selectSandboxRuntimeRoot.
|
||||
//
|
||||
// The ledger is deliberately unused here. A COMMAND is not a transaction: it
|
||||
// does not publish a marker and has nothing to roll back to, and a component
|
||||
// created on this path is the runtime tree the command is about to use. Only
|
||||
// setup owns an undo.
|
||||
root, lease, _, err := selectSandboxRuntimeRoot(workspaceRoot, true, sandboxHome)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
prepared := false
|
||||
defer func() {
|
||||
@@ -141,29 +77,30 @@ func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRun
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
// BEFORE ANYTHING IS CREATED. os.MkdirAll returns nil when Stat says the path
|
||||
// is already a directory, and Stat follows links, so a link planted at an
|
||||
// owned component is silently accepted and the whole tree is built inside
|
||||
// whatever it points at, and the root then becomes a write root the backend
|
||||
// binds read-write with TMPDIR and the build caches pointed inside it.
|
||||
if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
directories := []string{
|
||||
runtimeState.Root,
|
||||
runtimeState.Cache,
|
||||
runtimeState.Data,
|
||||
runtimeState.Temp,
|
||||
filepath.Join(runtimeState.Cache, "npm"),
|
||||
filepath.Join(runtimeState.Cache, "yarn"),
|
||||
filepath.Join(runtimeState.Cache, "corepack"),
|
||||
filepath.Join(runtimeState.Cache, "pip"),
|
||||
filepath.Join(runtimeState.Cache, "go-build"),
|
||||
filepath.Join(runtimeState.Data, "go-mod"),
|
||||
filepath.Join(runtimeState.Data, "cargo"),
|
||||
}
|
||||
if err := os.MkdirAll(runtimeState.Root, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", runtimeState.Root, err)
|
||||
for _, directory := range directories {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
|
||||
}
|
||||
if err := os.Chmod(directory, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
|
||||
}
|
||||
}
|
||||
// AND AGAIN AFTER, because the check above is a check-then-use on its own: a
|
||||
// component swapped during creation would still redirect the tree. Pairing the
|
||||
// two narrows the window to the creation itself.
|
||||
if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
// Everything below the root goes through a handle on it. The children are
|
||||
// the one part of the tree a sandboxed command can replace; see
|
||||
// prepareSandboxRuntimeTree.
|
||||
now := sandboxRuntimeNow()
|
||||
if err := prepareSandboxRuntimeTree(runtimeState.Root, now); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
|
||||
}
|
||||
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
|
||||
prepared = true
|
||||
@@ -171,19 +108,10 @@ func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRun
|
||||
}
|
||||
|
||||
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
return lease, err
|
||||
}
|
||||
|
||||
// prepareSandboxRuntimeLeaseRecording also reports the owned directories it
|
||||
// created, so a caller that can roll back knows what it owns.
|
||||
//
|
||||
// Nothing recorded here is created by the leaf's owner. Provisioning creates and
|
||||
// records the leaf itself; these are the components above it, which used to be
|
||||
// produced by an os.MkdirAll that nobody accounted for. Setup could therefore
|
||||
// fail after the lease and leave a tree behind with no record that it made it.
|
||||
func prepareSandboxRuntimeLeaseRecording(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
return acquireRuntimeLeaseForPlatform(root)
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
|
||||
}
|
||||
return acquireSandboxRuntimeLease(root)
|
||||
}
|
||||
|
||||
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
|
||||
@@ -246,60 +174,22 @@ func combineSandboxCleanups(cleanups ...func()) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Canonicalized for the same reason prepareSandboxRuntime canonicalizes the
|
||||
// workspace and cache roots: pathWithinRoot compares SPELLINGS, so a raw
|
||||
// os.TempDir() measured against a canonical workspace root compares two
|
||||
// different names for one directory and the containment check misses. Both
|
||||
// callers resolve this in the operator's environment, so the derived path is
|
||||
// identical on the setup and command sides and the plan hashes still agree.
|
||||
//
|
||||
// This closes the 8.3 short-name and symlink spellings, not every alias: a
|
||||
// Windows directory JUNCTION comes back from EvalSymlinks unchanged, and case
|
||||
// survives it on a case-insensitive macOS volume. The containment check below
|
||||
// therefore does not rely on canonicalization alone; runtimeRootWithinWorkspace
|
||||
// falls through to filesystem identity for exactly those aliases.
|
||||
tempRoot := canonicalSandboxWorkspaceRoot(os.TempDir())
|
||||
if tempRoot == "" || tempRoot == "." {
|
||||
return "", errors.New("temp directory is unavailable")
|
||||
fallbackSandboxRuntimes.Lock()
|
||||
defer fallbackSandboxRuntimes.Unlock()
|
||||
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
|
||||
return root, nil
|
||||
}
|
||||
// SCOPED TO THIS USER, unlike the cache-derived root, because this one lives
|
||||
// in shared temp. On Linux os.TempDir() is the world-writable /tmp whenever
|
||||
// TMPDIR is unset, and a digest of the workspace path alone is the same string
|
||||
// for every user on the host: two accounts working on the same path would name
|
||||
// one directory and the first one there would own it. The uid is not a secret
|
||||
// and is not doing secrecy work; it removes the collision, and
|
||||
// refuseAliasedRuntimeComponents handles somebody having got there first.
|
||||
digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope()))
|
||||
root := filepath.Join(append(append([]string{tempRoot}, sandboxRuntimeFallbackOwnedNames()...), hex.EncodeToString(digest[:8]))...)
|
||||
if runtimeRootWithinWorkspace(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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -336,302 +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
|
||||
}
|
||||
}
|
||||
|
||||
// selectSandboxRuntimeRoot picks the runtime root a command will actually use,
|
||||
// and holds a lease on it while the caller decides what to do with it.
|
||||
//
|
||||
// SETUP AND THE COMMAND HAVE TO SELECT THE SAME WAY, or they disagree about
|
||||
// which tree exists. Setup used to derive the cache-based root and fingerprint a
|
||||
// plan naming it, while a command derived the same root, failed to lease it, and
|
||||
// silently relocated to the temp fallback. The command's plan then named the
|
||||
// fallback and the marker rejected it:
|
||||
//
|
||||
// windows sandbox setup is out of date: permission roots or deny lists changed
|
||||
//
|
||||
// which blames permissions for a runtime-root disagreement, and re-running setup
|
||||
// could not recover because setup deterministically chose the same unleasable
|
||||
// root again. That is a permanent brick, not a retry.
|
||||
//
|
||||
// Extracted from prepareSandboxRuntime so both sides run this one function. The
|
||||
// caller must release the returned lease.
|
||||
// pinnedSandboxRuntimeRoot returns the root a previous setup recorded, when
|
||||
// that root is one this workspace could actually select.
|
||||
//
|
||||
// The candidate check is what keeps this honest. One sandbox home serves
|
||||
// whichever workspace ran setup last, so a recorded root can belong to a
|
||||
// different workspace entirely; pinning to that would point this command's
|
||||
// runtime at another workspace's tree. A recorded root is only honoured when it
|
||||
// matches one of the two roots THIS workspace derives, which is also the only
|
||||
// pair the selections could ever have disagreed about.
|
||||
// sandboxHome is the home THIS command asked for, not the one the parent
|
||||
// process happens to be pointed at.
|
||||
//
|
||||
// TWO ENVIRONMENT AUTHORITIES IS ONE TOO MANY. Runtime preparation ran before
|
||||
// Windows platform planning and resolved the home from the ambient environment,
|
||||
// while the planner resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own
|
||||
// spec.Env and hands THAT to the runner for marker validation. A request that
|
||||
// selects home B while the parent still points at home A pinned A's recorded
|
||||
// root into the profile, and the runner then loaded B's marker and rejected the
|
||||
// command as out of date even though setup for B was perfectly valid. The two
|
||||
// homes do not need different derivation rules to disagree, only different valid
|
||||
// selections from the same preferred/fallback pair.
|
||||
//
|
||||
// Empty means no command context, so the ambient environment is the only
|
||||
// authority there is and resolving it here is correct.
|
||||
func pinnedSandboxRuntimeRoot(workspaceRoot, preferred, fallback, sandboxHome string) string {
|
||||
// No GOOS gate. The marker only exists where setup wrote one, so this is
|
||||
// already Windows-only in practice, and keeping the code path platform-neutral
|
||||
// means the setup-to-command contract is exercised on every CI runner instead
|
||||
// of only the Windows one.
|
||||
home := strings.TrimSpace(sandboxHome)
|
||||
if home == "" {
|
||||
resolved, err := ResolveWindowsSandboxHome(nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
home = resolved
|
||||
}
|
||||
recorded := WindowsSandboxRecordedRuntimeRoot(home)
|
||||
if recorded == "" {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{preferred, fallback} {
|
||||
if candidate != "" && sameWindowsRuntimeRootPath(recorded, candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// A RECORD IS HISTORY, NOT SOMETHING TO RE-DERIVE.
|
||||
//
|
||||
// The candidates above are both computed from THIS process's environment, and
|
||||
// the fallback one is computed from its TEMP. Setup can legitimately land on
|
||||
// the fallback when the preferred cache root cannot be leased, and it records
|
||||
// that concrete root. If a later IDE, service or terminal runs with a
|
||||
// different TEMP, the recorded root matches neither of today's candidates, the
|
||||
// record is ignored, and selection picks a tree setup never provisioned. The
|
||||
// runner then rejects the marker as out of date, and re-running setup from the
|
||||
// original environment does not make the other environment converge.
|
||||
//
|
||||
// So the recorded root is recognised by its OWN shape instead: it must be a
|
||||
// runtime root Zero owns, its first component must be the fallback's, and its
|
||||
// leaf must be this workspace's fallback digest. That answers "does this
|
||||
// record belong to this workspace and home" without asking "what would I
|
||||
// choose from scratch right now", which is the question that reintroduced the
|
||||
// second selection.
|
||||
//
|
||||
// The cache-derived digest is deliberately NOT accepted here. A moved user
|
||||
// cache stays stale, which is a different failure with a different remedy.
|
||||
if ownedFallbackRuntimeRecord(workspaceRoot, recorded) {
|
||||
return recorded
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ownedFallbackRuntimeRecord reports whether recorded is a fallback runtime root
|
||||
// this workspace would own, judged on the record's own shape.
|
||||
func ownedFallbackRuntimeRecord(workspaceRoot, recorded string) bool {
|
||||
workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot)
|
||||
if workspaceRoot == "" || strings.TrimSpace(recorded) == "" {
|
||||
return false
|
||||
}
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(recorded)
|
||||
if !owned || base == "" || len(components) == 0 {
|
||||
return false
|
||||
}
|
||||
names := sandboxRuntimeFallbackOwnedNames()
|
||||
if len(names) == 0 || !strings.EqualFold(components[0], names[0]) {
|
||||
return false
|
||||
}
|
||||
digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope()))
|
||||
return strings.EqualFold(components[len(components)-1], hex.EncodeToString(digest[:8]))
|
||||
}
|
||||
|
||||
// resolveSandboxRuntimeRootInputs is the part of runtime-root selection that
|
||||
// names things and touches nothing: the canonical workspace, and the root a
|
||||
// command prefers for it. It takes no lease and creates no directory.
|
||||
//
|
||||
// ONE RESOLVER, BECAUSE THERE WERE TWO AND THEY DISAGREED. Selection and the
|
||||
// diagnostic that asks "would a command still select the recorded root?" each
|
||||
// spelled these steps out, and the diagnostic's copy was missing one: a user
|
||||
// cache directory that resolves to nothing. os.UserCacheDir can succeed and
|
||||
// still hand back a value that canonicalizes to "" or ".", and selection fails
|
||||
// closed on that with "user cache directory is unavailable" while the
|
||||
// diagnostic carried on and derived candidates from the empty root. Doctor
|
||||
// could then report the recorded root as current, or as merely stale, on a
|
||||
// machine where every command fails before it ever looks at a marker. That is
|
||||
// the false-healthy signal this whole change exists to remove, reached through
|
||||
// a different edge of the same resolver.
|
||||
//
|
||||
// The diagnostic's doc comment already said it asked the command's question
|
||||
// through the command's function. Now it does: both callers get their inputs
|
||||
// here, so an input one of them refuses is refused by the other, with the same
|
||||
// error. Reported by @jatmn.
|
||||
func resolveSandboxRuntimeRootInputs(workspaceRoot string) (workspace string, preferred string, err error) {
|
||||
workspace = canonicalSandboxWorkspaceRoot(workspaceRoot)
|
||||
if workspace == "" || workspace == "." {
|
||||
return "", "", errors.New("sandbox runtime requires a workspace root")
|
||||
}
|
||||
cacheRoot, err := sandboxUserCacheDir()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve user cache directory: %w", err)
|
||||
}
|
||||
cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot)
|
||||
if cacheRoot == "" || cacheRoot == "." {
|
||||
return "", "", errors.New("user cache directory is unavailable")
|
||||
}
|
||||
preferred, err = sandboxRuntimeRootFor(workspace, cacheRoot)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return workspace, preferred, nil
|
||||
}
|
||||
|
||||
// WindowsSandboxRecordedRuntimeRootIsCurrent answers the question a diagnostic
|
||||
// has to ask before trusting the marker: would a command run NOW still select
|
||||
// the runtime root that setup recorded?
|
||||
//
|
||||
// The marker's root is a historical fact, needed to check the stamp without
|
||||
// mutating anything. It is not proof that the root is still selectable. Setup
|
||||
// can run with the user cache at A, the cache can then move so commands derive
|
||||
// B, and the stamped A tree stays behind. A diagnostic that pins A reports a
|
||||
// healthy machine while every real command rejects A as not a current
|
||||
// candidate and fails on the out-of-date marker.
|
||||
//
|
||||
// This derives the same candidates a command derives, through the same
|
||||
// resolver, and applies the same equality, by calling the same function the
|
||||
// command path calls. It takes no lease and creates nothing.
|
||||
func WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot string) (recorded string, current bool, err error) {
|
||||
recorded = WindowsSandboxRecordedRuntimeRoot(sandboxHome)
|
||||
if recorded == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
workspaceRoot, preferred, err := resolveSandboxRuntimeRootInputs(workspaceRoot)
|
||||
if err != nil {
|
||||
return recorded, false, err
|
||||
}
|
||||
fallback, _ := fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
return recorded, pinnedSandboxRuntimeRoot(workspaceRoot, preferred, fallback, sandboxHome) != "", nil
|
||||
}
|
||||
|
||||
// selectSandboxRuntimeRoot picks the runtime root and returns the lease on it
|
||||
// together with the directories acquiring that lease had to create.
|
||||
//
|
||||
// honorRecorded is true on the command side and false during setup: setup is
|
||||
// making the choice, so it must not consult a record it is about to overwrite,
|
||||
// or a single unlucky relocation to the temp fallback would pin every future
|
||||
// setup to temp.
|
||||
//
|
||||
// THE LEDGER IS PART OF THE ANSWER, NOT A DETAIL OF IT. Acquiring the lease
|
||||
// creates zero/runtime/v1 when they are not there, and this was the two-result
|
||||
// wrapper that dropped that fact on the floor. Setup then provisioned the leaf,
|
||||
// recorded only the leaf for rollback, and any failure before the marker left
|
||||
// the parents behind with nothing that knew it had made them. An error carries
|
||||
// the partial ledger out for the same reason: acquisition can create one
|
||||
// component and fail on the next.
|
||||
func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool, sandboxHome string) (string, *sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
workspaceRoot, root, err := resolveSandboxRuntimeRootInputs(workspaceRoot)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
// CONSUME SETUP'S CHOICE, do not re-make it. Everything below is a fresh
|
||||
// selection whose answer depends on whether a lease can be taken right now,
|
||||
// and a command reaching a different answer than setup did is the outage this
|
||||
// contract exists to prevent: the tree the command names was never
|
||||
// provisioned, so its ACL plan hash cannot match and no amount of re-running
|
||||
// setup fixes it.
|
||||
if honorRecorded {
|
||||
fallbackRoot, _ := fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if pinned := pinnedSandboxRuntimeRoot(workspaceRoot, root, fallbackRoot, sandboxHome); pinned != "" {
|
||||
lease, pinnedCreated, leaseErr := prepareSandboxRuntimeLeaseRecording(pinned)
|
||||
if leaseErr != nil {
|
||||
// NOT relocated. Relocating is what produced the permanent brick:
|
||||
// the other root has no capability ACL, so the command would be
|
||||
// rejected anyway, with a message about permissions. Failing here
|
||||
// says the true thing and points at the action that fixes it.
|
||||
return "", nil, pinnedCreated, fmt.Errorf("sandbox runtime root %s was provisioned by setup but cannot be used now (%w); "+
|
||||
"re-run `zero sandbox setup` from an elevated (Administrator) terminal", pinned, leaseErr)
|
||||
}
|
||||
return pinned, lease, pinnedCreated, nil
|
||||
}
|
||||
}
|
||||
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err == nil {
|
||||
return root, lease, created, nil
|
||||
}
|
||||
// AN ALIASED COMPONENT IS NOT A REASON TO RELOCATE. Falling back here would
|
||||
// leave the link in place, report nothing, and move to the next predictable
|
||||
// name, which the same attacker can take as well. Relocating is for a root
|
||||
// that is merely unusable.
|
||||
if errors.Is(err, errRuntimeComponentAliased) {
|
||||
return "", nil, created, err
|
||||
}
|
||||
// The preferred root could not be leased. Relocating is right, and it is what
|
||||
// commands already did; the defect was that setup never learned about it.
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return "", nil, created, err
|
||||
}
|
||||
// APPENDED, not replaced. The preferred attempt may have created components
|
||||
// before it failed to lease, and those are this invocation's too.
|
||||
fallbackLease, fallbackCreated, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
created = append(created, fallbackCreated...)
|
||||
if err != nil {
|
||||
return "", nil, created, err
|
||||
}
|
||||
return root, fallbackLease, created, nil
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace, "")
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
if err := os.Chtimes(expired, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, release, err := prepareSandboxRuntime(workspace, "")
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
@@ -71,34 +71,15 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
|
||||
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
// BOTH derivation inputs belong to the test, not just the cache one. The
|
||||
// fallback root is derived from os.TempDir(), which reads TMPDIR on Unix and
|
||||
// TMP/TEMP on Windows, and its name is deterministic, so stubbing only the
|
||||
// cache left this test creating a persistent tree in the developer real temp
|
||||
// directory that later runs then found already present.
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempHome)
|
||||
t.Setenv("TMP", tempHome)
|
||||
t.Setenv("TEMP", tempHome)
|
||||
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace, "")
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
release()
|
||||
// Only the root this test created.
|
||||
_ = os.RemoveAll(runtimeState.Root)
|
||||
}()
|
||||
// And the redirect actually took, or the cleanup above removes one tree while
|
||||
// the real one is still left behind somewhere else.
|
||||
if !pathWithinRoot(canonicalSandboxWorkspaceRoot(tempHome), runtimeState.Root) {
|
||||
t.Fatalf("fallback runtime root %q is outside the test-owned temp directory %q", runtimeState.Root, tempHome)
|
||||
}
|
||||
defer release()
|
||||
if pathWithinRoot(workspace, runtimeState.Root) {
|
||||
t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
|
||||
}
|
||||
@@ -120,7 +101,7 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
|
||||
sandboxRuntimeNow = originalNow
|
||||
})
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace, "")
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runtimeTreeChildren are the directories prepared inside every runtime root,
|
||||
// relative to it, each listed after its parent.
|
||||
var runtimeTreeChildren = []string{
|
||||
"cache",
|
||||
"data",
|
||||
"tmp",
|
||||
filepath.Join("cache", "npm"),
|
||||
filepath.Join("cache", "yarn"),
|
||||
filepath.Join("cache", "corepack"),
|
||||
filepath.Join("cache", "pip"),
|
||||
filepath.Join("cache", "go-build"),
|
||||
filepath.Join("data", "go-mod"),
|
||||
filepath.Join("data", "cargo"),
|
||||
}
|
||||
|
||||
// prepareSandboxRuntimeTree creates and secures the runtime root's own
|
||||
// directories through a handle on the root, and refuses any that is a link.
|
||||
//
|
||||
// THE CHILDREN ARE THE SANDBOX'S TO CHANGE. The root is a write root: every
|
||||
// backend grants or binds it read-write so a sandboxed command can use its TMP
|
||||
// and caches, and that same grant lets the command remove cache, data or tmp
|
||||
// and leave a link to a host directory in its place. The tree outlives the
|
||||
// command, and the fallback root is derived rather than minted, so the NEXT Zero
|
||||
// process prepares the same tree, unsandboxed. It used to MkdirAll and Chmod each
|
||||
// child by pathname after checking only the root and its ancestors, which
|
||||
// followed the planted link: a contained command could get Zero to create npm
|
||||
// inside a host directory and change that directory's mode. Reported by @jatmn.
|
||||
//
|
||||
// So nothing below the root is reached by pathname. os.Root resolves every
|
||||
// component relative to the root's handle and refuses one that leads out of it,
|
||||
// which covers the parent of every grandchild too; a child that is a link at
|
||||
// all is refused outright, because nothing but tampering puts one there; and
|
||||
// the mode is set on the opened directory rather than on a name that could be
|
||||
// swapped between the check and the change.
|
||||
func prepareSandboxRuntimeTree(root string, now time.Time) error {
|
||||
tree, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sandbox runtime root %s: %w", root, err)
|
||||
}
|
||||
defer func() { _ = tree.Close() }()
|
||||
if err := secureRuntimeTreeDirectory(tree, ".", root); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range runtimeTreeChildren {
|
||||
path := filepath.Join(root, child)
|
||||
if err := tree.Mkdir(child, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return fmt.Errorf("create sandbox runtime directory %s: %w", path, err)
|
||||
}
|
||||
info, err := tree.Lstat(child)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect sandbox runtime directory %s: %w", path, err)
|
||||
}
|
||||
// ModeIrregular as well as ModeSymlink: a Windows junction reports as
|
||||
// irregular, and needs no privilege to create.
|
||||
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
|
||||
return fmt.Errorf("%w: refusing to prepare the sandbox runtime through a link at %s, which a command run inside "+
|
||||
"the sandbox can leave there to redirect Zero's own writes; remove %s and Zero will recreate it",
|
||||
errRuntimeComponentAliased, path, root)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("sandbox runtime directory %s exists and is not a directory; remove %s and Zero will recreate it", path, root)
|
||||
}
|
||||
if err := secureRuntimeTreeDirectory(tree, child, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tree.Chtimes(".", now, now); err != nil {
|
||||
return fmt.Errorf("touch sandbox runtime root: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secureRuntimeTreeDirectory sets 0700 on the directory it opened, not on a
|
||||
// name, so a swap after the check cannot move the change onto something else.
|
||||
//
|
||||
// Not on Windows, where a directory's access is its ACL, which setup manages,
|
||||
// and chmod only toggles a read-only attribute Windows does not honour on
|
||||
// directories. Setting it there would also need write-attribute access this
|
||||
// handle does not have, for no effect.
|
||||
func secureRuntimeTreeDirectory(tree *os.Root, name string, path string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
directory, err := tree.Open(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sandbox runtime directory %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = directory.Close() }()
|
||||
if err := directory.Chmod(0o700); err != nil {
|
||||
return fmt.Errorf("secure sandbox runtime directory %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LINK LEFT IN THE TREE BY ONE COMMAND IS NOT FOLLOWED BY THE NEXT ZERO.
|
||||
//
|
||||
// The runtime root is a write root, so a sandboxed command can replace any
|
||||
// directory under it with a link to a host directory it cannot write. The
|
||||
// fallback root is derived rather than minted, so the next Zero process,
|
||||
// unsandboxed, prepares that same tree. It created and chmodded every child by
|
||||
// pathname and followed the link: jatmn's probe got outside/npm created and the
|
||||
// outside directory's mode changed through cache. Reported by @jatmn.
|
||||
//
|
||||
// Every child is covered, grandchildren included, because each is its own
|
||||
// pathname and closing one leaves the others open.
|
||||
|
||||
// plantRuntimeLink replaces nothing; it makes link point at target, the way a
|
||||
// sandboxed command would: a symlink off Windows, and a junction on Windows,
|
||||
// where a symlink needs a privilege and a junction does not.
|
||||
func plantRuntimeLink(t *testing.T, link, target string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
|
||||
t.Fatalf("SETUP INVALID: plant a junction at %s: %v %s", link, err, out)
|
||||
}
|
||||
} else if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("SETUP INVALID: plant a symlink at %s: %v", link, err)
|
||||
}
|
||||
linkInfo, err := os.Stat(link)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: the planted link does not resolve: %v", err)
|
||||
}
|
||||
targetInfo, err := os.Stat(target)
|
||||
if err != nil || !os.SameFile(linkInfo, targetInfo) {
|
||||
t.Fatalf("SETUP INVALID: %s does not lead to %s, so the prepare below would prove nothing", link, target)
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackRuntimeForTest makes the next prepareSandboxRuntime choose the temp
|
||||
// fallback, inside a temp directory this test owns.
|
||||
func fallbackRuntimeForTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
redirectSandboxTestTemp(t, t.TempDir())
|
||||
original := sandboxUserCacheDir
|
||||
// A cache inside the workspace puts the preferred root there, which is what
|
||||
// sends selection to the fallback.
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
return workspace
|
||||
}
|
||||
|
||||
func TestPreparingTheRuntimeDoesNotFollowAChildLinkLeftByACommand(t *testing.T) {
|
||||
for _, child := range runtimeTreeChildren {
|
||||
t.Run(filepath.ToSlash(child), func(t *testing.T) {
|
||||
workspace := fallbackRuntimeForTest(t)
|
||||
first, release, err := prepareSandboxRuntime(workspace, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: the first prepare failed: %v", err)
|
||||
}
|
||||
release()
|
||||
requireWithinTestOwned(t, first.Root, os.TempDir())
|
||||
if pathWithinRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(first.Root)) {
|
||||
t.Fatalf("SETUP INVALID: %s is the preferred root inside the workspace, not the fallback", first.Root)
|
||||
}
|
||||
|
||||
// A host directory the command cannot write inside the sandbox, with a
|
||||
// mode a chmod through the link would change.
|
||||
outside := t.TempDir()
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
planted := filepath.Join(first.Root, child)
|
||||
if err := os.RemoveAll(planted); err != nil {
|
||||
t.Fatalf("SETUP INVALID: remove %s: %v", planted, err)
|
||||
}
|
||||
plantRuntimeLink(t, planted, outside)
|
||||
|
||||
second, release, err := prepareSandboxRuntime(workspace, "")
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatalf("the next prepare accepted a link at %s (root %s)", child, second.Root)
|
||||
}
|
||||
if !errors.Is(err, errRuntimeComponentAliased) {
|
||||
t.Fatalf("refused for a reason other than the link, so this does not pin the guard: %v", err)
|
||||
}
|
||||
entries, readErr := os.ReadDir(outside)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read the outside directory: %v", readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("the prepare created %d entries inside the directory the link pointed at, starting with %s", len(entries), entries[0].Name())
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, statErr := os.Stat(outside)
|
||||
if statErr != nil {
|
||||
t.Fatal(statErr)
|
||||
}
|
||||
if info.Mode().Perm() != 0o755 {
|
||||
t.Errorf("the prepare changed the mode of the directory the link pointed at to %04o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case still works through the handle: a second prepare of an
|
||||
// intact tree succeeds, and off Windows it puts a loosened child back to 0700.
|
||||
func TestPreparingAnIntactRuntimeTwiceStillSecuresItsChildren(t *testing.T) {
|
||||
workspace := fallbackRuntimeForTest(t)
|
||||
first, release, err := prepareSandboxRuntime(workspace, "")
|
||||
if err != nil {
|
||||
t.Fatalf("the first prepare failed: %v", err)
|
||||
}
|
||||
release()
|
||||
requireWithinTestOwned(t, first.Root, os.TempDir())
|
||||
loosened := filepath.Join(first.Root, "cache", "npm")
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(loosened, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, release, err = prepareSandboxRuntime(workspace, "")
|
||||
if err != nil {
|
||||
t.Fatalf("the second prepare of an intact tree failed: %v", err)
|
||||
}
|
||||
release()
|
||||
for _, child := range runtimeTreeChildren {
|
||||
info, err := os.Lstat(filepath.Join(first.Root, child))
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("%s is missing or not a directory after prepare: %v", child, err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("%s is %04o after prepare, want 0700", child, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// currentProcessSID has no meaning off Windows. Windows sandbox setup args are
|
||||
// only ever built on Windows; this exists so the shared builder compiles.
|
||||
func currentProcessSID() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// currentProcessSID is the SID of the token running this process.
|
||||
//
|
||||
// Called from BuildWindowsSandboxSetupArgs, which runs in the OPERATOR'S shell
|
||||
// before elevation, so it answers "who will read the stamp afterwards" rather
|
||||
// than "who is provisioning it". Those are different principals whenever setup
|
||||
// elevates, and the difference is the whole point: the elevated helper creates
|
||||
// the runtime leaf when it is absent, so anything inferred from that leaf
|
||||
// describes the installer and not the consumer.
|
||||
func currentProcessSID() (string, error) {
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve the calling user SID for sandbox setup: %w", err)
|
||||
}
|
||||
sid := user.User.Sid
|
||||
if sid == nil {
|
||||
return "", fmt.Errorf("resolve the calling user SID for sandbox setup: the token carried none")
|
||||
}
|
||||
return sid.String(), nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// redirectSandboxTestTemp points EVERY variable os.TempDir reads at dir.
|
||||
//
|
||||
// os.TempDir reads TMPDIR on Unix and TMP, then TEMP, on Windows. A platform
|
||||
// neutral test that sets only the Windows pair changes nothing on Unix, where
|
||||
// fallbackSandboxRuntimeRoot then derives from the ambient temp: a path outside
|
||||
// every t.TempDir the test owns, which the test goes on to create or clear. The
|
||||
// cache resolver override does not reach this input, so a fixture that redirects
|
||||
// the cache and two of the three variables still has one production input it
|
||||
// does not own. Reported by @jatmn.
|
||||
//
|
||||
// It also verifies the redirect took, because a redirect that silently did
|
||||
// nothing is exactly how this went unnoticed: the tests passed, or skipped, and
|
||||
// the tree they made was somewhere else.
|
||||
func redirectSandboxTestTemp(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("TMPDIR", dir)
|
||||
t.Setenv("TMP", dir)
|
||||
t.Setenv("TEMP", dir)
|
||||
got := canonicalSandboxWorkspaceRoot(os.TempDir())
|
||||
want := canonicalSandboxWorkspaceRoot(dir)
|
||||
if !sameWindowsRuntimeRootPath(got, want) {
|
||||
t.Fatalf("SETUP INVALID: os.TempDir() is %s after redirecting it to %s, so anything derived from temp lands outside this test", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// requireWithinTestOwned fails unless path is inside one of the directories the
|
||||
// test owns, compared canonically on both sides because the derivation
|
||||
// canonicalizes and a raw t.TempDir() can be a second spelling of the same place
|
||||
// (/var against /private/var, an 8.3 profile name on a Windows runner).
|
||||
//
|
||||
// Called BEFORE a derived path is created or removed. Checking afterwards
|
||||
// reports a tree in somebody else's directory once it already exists.
|
||||
func requireWithinTestOwned(t *testing.T, path string, owned ...string) {
|
||||
t.Helper()
|
||||
canonical := canonicalSandboxWorkspaceRoot(path)
|
||||
for _, root := range owned {
|
||||
if pathWithinRoot(canonicalSandboxWorkspaceRoot(root), canonical) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s (canonically %s) is outside every directory this test owns %v; refusing to create or remove it", path, canonical, owned)
|
||||
}
|
||||
|
||||
// testStampPlanHash is the plan the stamp tests attest. The stamp is named after
|
||||
// the plan it proves, so a test that writes one and then looks for it has to use
|
||||
// the same plan on both sides; one shared value keeps that from being a typo.
|
||||
const testStampPlanHash = "planhash"
|
||||
@@ -1,216 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
aceAdmissionLegEnv = "ZERO_TEST_ACE_ADMISSION_LEG"
|
||||
|
||||
// Not in x/sys/windows. Values from winnt.h.
|
||||
testAccessAllowedObjectACEType = 0x5
|
||||
testAccessAllowedCallbackACEType = 0x9
|
||||
|
||||
aceAdmissionTrustee = "S-1-5-21-1111-2222-3333-4444"
|
||||
)
|
||||
|
||||
// rawACE lays out an ACE by hand: header, mask, then whatever the leg wants to
|
||||
// sit where a plain ACE keeps its SID.
|
||||
func rawACE(aceType, aceFlags byte, mask uint32, tail []byte) []byte {
|
||||
ace := make([]byte, windowsPlainACEHeaderSize+len(tail))
|
||||
ace[0] = aceType
|
||||
ace[1] = aceFlags
|
||||
binary.LittleEndian.PutUint16(ace[2:], uint16(len(ace)))
|
||||
binary.LittleEndian.PutUint32(ace[4:], mask)
|
||||
copy(ace[windowsPlainACEHeaderSize:], tail)
|
||||
return ace
|
||||
}
|
||||
|
||||
// aclEndingAtAGuardPage builds an ACL whose last byte is the last readable byte
|
||||
// before a PAGE_NOACCESS page. A reader that trusts a length the entry did not
|
||||
// vouch for walks off the end and faults, instead of quietly reading whatever
|
||||
// the heap had next, which is what makes an over-read observable at all.
|
||||
func aclEndingAtAGuardPage(t *testing.T, aces ...[]byte) *windows.ACL {
|
||||
t.Helper()
|
||||
size := 8
|
||||
for _, ace := range aces {
|
||||
size += len(ace)
|
||||
}
|
||||
if size%4 != 0 {
|
||||
t.Fatalf("SETUP INVALID: ACL size %d is not DWORD aligned", size)
|
||||
}
|
||||
page := os.Getpagesize()
|
||||
base, err := windows.VirtualAlloc(0, uintptr(2*page), windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
|
||||
if err != nil {
|
||||
t.Fatalf("VirtualAlloc: %v", err)
|
||||
}
|
||||
var previous uint32
|
||||
if err := windows.VirtualProtect(base+uintptr(page), uintptr(page), windows.PAGE_NOACCESS, &previous); err != nil {
|
||||
t.Fatalf("VirtualProtect: %v", err)
|
||||
}
|
||||
start := base + uintptr(page) - uintptr(size)
|
||||
// The address is not Go memory, so it is turned into a pointer without the
|
||||
// uintptr-to-Pointer conversion vet rejects.
|
||||
region := unsafe.Slice((*byte)(*(*unsafe.Pointer)(unsafe.Pointer(&start))), size)
|
||||
region[0] = 4 // ACL_REVISION_DS, the revision that admits object ACEs
|
||||
binary.LittleEndian.PutUint16(region[2:], uint16(size))
|
||||
binary.LittleEndian.PutUint16(region[4:], uint16(len(aces)))
|
||||
offset := 8
|
||||
for _, ace := range aces {
|
||||
offset += copy(region[offset:], ace)
|
||||
}
|
||||
return (*windows.ACL)(unsafe.Pointer(®ion[0]))
|
||||
}
|
||||
|
||||
func sidBytes(t *testing.T, sid *windows.SID) []byte {
|
||||
t.Helper()
|
||||
return append([]byte(nil), unsafe.Slice((*byte)(unsafe.Pointer(sid)), int(windows.GetLengthSid(sid)))...)
|
||||
}
|
||||
|
||||
// TestACEAdmissionHelperProcess is the child. Every leg puts an ACL against a
|
||||
// guard page, so a regression is an access violation, and an access violation
|
||||
// in the test binary itself would take every other test in the package down
|
||||
// with it and could be misread as a pass.
|
||||
func TestACEAdmissionHelperProcess(t *testing.T) {
|
||||
leg := os.Getenv(aceAdmissionLegEnv)
|
||||
if leg == "" {
|
||||
return
|
||||
}
|
||||
wanted, err := windows.StringToSid(aceAdmissionTrustee)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, required, err := windowsACLAccess(WindowsACLAllowWrite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
whole := sidBytes(t, wanted)
|
||||
// The first sub-authority and no more: revision, count and authority all
|
||||
// match the trustee, so a comparison that believes the count keeps going.
|
||||
prefix := whole[:12]
|
||||
inherit := byte(windows.CONTAINER_INHERIT_ACE | windows.OBJECT_INHERIT_ACE)
|
||||
|
||||
var acl *windows.ACL
|
||||
switch leg {
|
||||
case "plain":
|
||||
acl = aclEndingAtAGuardPage(t, rawACE(windows.ACCESS_ALLOWED_ACE_TYPE, inherit, uint32(required), whole))
|
||||
case "object":
|
||||
acl = aclEndingAtAGuardPage(t, rawACE(testAccessAllowedObjectACEType, inherit, uint32(required), prefix))
|
||||
case "callback":
|
||||
acl = aclEndingAtAGuardPage(t, rawACE(testAccessAllowedCallbackACEType, inherit, uint32(required), prefix))
|
||||
case "truncated":
|
||||
acl = aclEndingAtAGuardPage(t, rawACE(windows.ACCESS_ALLOWED_ACE_TYPE, inherit, uint32(required), prefix))
|
||||
case "object then plain":
|
||||
// An unreadable entry must not end the walk: the grant behind it counts.
|
||||
acl = aclEndingAtAGuardPage(t,
|
||||
rawACE(testAccessAllowedObjectACEType, inherit, uint32(required), prefix),
|
||||
rawACE(windows.ACCESS_ALLOWED_ACE_TYPE, inherit, uint32(required), whole))
|
||||
default:
|
||||
t.Fatalf("unknown leg %q", leg)
|
||||
}
|
||||
fmt.Printf("carries=%v\n", windowsDACLCarriesGrant(acl, wanted, required, inherit))
|
||||
}
|
||||
|
||||
// ONLY A PLAIN ALLOW OR DENY ACE IS READ AS ONE.
|
||||
//
|
||||
// The attestation walks the DACL and compared every entry's would-be SID with
|
||||
// the capability before it looked at the entry's type. Only ACCESS_ALLOWED_ACE
|
||||
// and ACCESS_DENIED_ACE keep a SID directly behind the mask; an object ACE has a
|
||||
// flags word and GUIDs there, and EqualSid is undefined for something that is
|
||||
// not a SID. The bytes below are arranged so that the misreading is not
|
||||
// hypothetical: each entry's tail spells the START of the trustee's SID, so its
|
||||
// revision and sub-authority count match and the comparison runs on for the
|
||||
// length that count claims, which is past the end of the entry and into a page
|
||||
// that cannot be read. With the type and size admitted first nothing is read
|
||||
// past the header, the legs answer false, and the plain entry still answers
|
||||
// true so the walk has not simply stopped crediting anything.
|
||||
//
|
||||
// Each leg runs in a child process. An access violation cannot be recovered in
|
||||
// Go, and one in this binary would end the package's other tests with it.
|
||||
// Reported by @jatmn.
|
||||
func TestOnlyPlainACEsAreReadDuringAttestation(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
leg string
|
||||
want bool
|
||||
}{
|
||||
{leg: "plain", want: true},
|
||||
{leg: "object", want: false},
|
||||
{leg: "callback", want: false},
|
||||
{leg: "truncated", want: false},
|
||||
{leg: "object then plain", want: true},
|
||||
} {
|
||||
t.Run(testCase.leg, func(t *testing.T) {
|
||||
child := exec.Command(os.Args[0], "-test.run=^TestACEAdmissionHelperProcess$", "-test.count=1")
|
||||
child.Env = append(os.Environ(), aceAdmissionLegEnv+"="+testCase.leg)
|
||||
output, err := child.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("the attestation did not survive this entry (%v); an entry was read in a layout its type does not have:\n%s", err, lastLines(string(output), 12))
|
||||
}
|
||||
want := fmt.Sprintf("carries=%v", testCase.want)
|
||||
if !strings.Contains(string(output), want) {
|
||||
t.Fatalf("want %s, got:\n%s", want, lastLines(string(output), 12))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lastLines(text string, count int) string {
|
||||
lines := strings.Split(strings.TrimRight(text, "\r\n"), "\n")
|
||||
if len(lines) > count {
|
||||
lines = lines[len(lines)-count:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// THE TYPE RULE HAS TO HOLD ON ITS OWN. The guard-page legs above are also
|
||||
// stopped by the size rule, because their tails are short, so they pass with the
|
||||
// type rule removed. An object or callback entry can just as well be long enough
|
||||
// to hold the SID its would-be count claims, and then only its type says it is
|
||||
// not a plain ACE. Here every entry carries the trustee's whole SID at the plain
|
||||
// offset, which is the layout that would make a misread entry compare equal, and
|
||||
// only the two plain types may be admitted.
|
||||
func TestOnlyPlainACETypesAreAdmitted(t *testing.T) {
|
||||
wanted, err := windows.StringToSid(aceAdmissionTrustee)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
whole := sidBytes(t, wanted)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
aceType byte
|
||||
admit bool
|
||||
}{
|
||||
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, admit: true},
|
||||
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE, admit: true},
|
||||
{name: "allowed object", aceType: 0x5, admit: false},
|
||||
{name: "denied object", aceType: 0x6, admit: false},
|
||||
{name: "allowed callback", aceType: 0x9, admit: false},
|
||||
{name: "denied callback", aceType: 0xA, admit: false},
|
||||
{name: "allowed callback object", aceType: 0xB, admit: false},
|
||||
{name: "mandatory label", aceType: 0x11, admit: false},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
raw := rawACE(testCase.aceType, 0, 0x1F01FF, whole)
|
||||
ace, sid := windowsPlainACESID((*windows.ACE_HEADER)(unsafe.Pointer(&raw[0])))
|
||||
if (ace != nil) != testCase.admit {
|
||||
t.Fatalf("type 0x%X admitted=%v, want %v", testCase.aceType, ace != nil, testCase.admit)
|
||||
}
|
||||
if testCase.admit && !sid.Equals(wanted) {
|
||||
t.Errorf("an admitted plain entry did not yield its SID")
|
||||
}
|
||||
})
|
||||
}
|
||||
if ace, _ := windowsPlainACESID(nil); ace != nil {
|
||||
t.Error("a nil header was admitted")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unsafe"
|
||||
@@ -36,160 +35,13 @@ type windowsACLSnapshot struct {
|
||||
Path string
|
||||
Descriptor *windows.SECURITY_DESCRIPTOR
|
||||
Materialized bool
|
||||
// Identity is the object the forward apply actually modified, captured from
|
||||
// the open handle. Compensation reopens BY NAME, and a name is not an
|
||||
// object: see rollbackWindowsACLSnapshots.
|
||||
Identity windowsObjectIdentity
|
||||
}
|
||||
|
||||
// windowsObjectIdentity identifies a filesystem object independently of the
|
||||
// name it currently answers to.
|
||||
type windowsObjectIdentity struct {
|
||||
volume uint32
|
||||
high uint32
|
||||
low uint32
|
||||
valid bool
|
||||
}
|
||||
|
||||
func windowsIdentityFromHandle(handle windows.Handle) windowsObjectIdentity {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return windowsObjectIdentity{}
|
||||
}
|
||||
return windowsObjectIdentity{
|
||||
volume: info.VolumeSerialNumber,
|
||||
high: info.FileIndexHigh,
|
||||
low: info.FileIndexLow,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// matches is deliberately false when either side is unknown. A compensation
|
||||
// that cannot prove it is acting on the object it changed must not act.
|
||||
func (id windowsObjectIdentity) matches(other windowsObjectIdentity) bool {
|
||||
return id.valid && other.valid &&
|
||||
id.volume == other.volume && id.high == other.high && id.low == other.low
|
||||
}
|
||||
|
||||
// windowsACLStampRequest asks the apply to write the runtime setup stamp THROUGH
|
||||
// THE SAME HANDLE it just applied the capability ACE through.
|
||||
//
|
||||
// The stamp exists to prove that the directory a command later uses is the
|
||||
// object setup granted the ACE to. Writing it afterwards by pathname cannot
|
||||
// prove that, however carefully the second open is done: the ACE goes on
|
||||
// through a rooted handle, that handle closes, network setup runs, and only then
|
||||
// does the marker write re-open the name. A local process that can reach the
|
||||
// user-owned runtime tree can remove the predictable root in that window and
|
||||
// put an ordinary directory in its place. The re-open correctly rejects a
|
||||
// junction, but an ordinary replacement is not a reparse point and is not the
|
||||
// ACL-bearing object either, so it collects a valid-looking stamp. Marker
|
||||
// validation then passes over a directory with no capability ACE, and the next
|
||||
// WRITE_RESTRICTED command fails its cache and TMP writes with setup insisting
|
||||
// it is current.
|
||||
//
|
||||
// Closing that means never naming the target again after the ACE lands. The
|
||||
// hash is known before the apply, so the stamp can simply ride along.
|
||||
type windowsACLStampRequest struct {
|
||||
Root string
|
||||
PlanHash string
|
||||
// RootIdentity is the file identity of the runtime directory the SNAPSHOT
|
||||
// read, and RootIdentified says whether that capture actually succeeded.
|
||||
//
|
||||
// TWO OPENS OF ONE NAME ARE NOT ONE OBJECT. The snapshot proved "these prior
|
||||
// bytes belong to B" through its own handle and then closed it; the apply
|
||||
// resolved the same pathname again and mutated whatever answered, without
|
||||
// either of them ever proving B and that object are the same. The root owner
|
||||
// can rename the prior root aside, put an ordinary directory at the
|
||||
// predictable name for the snapshot, and restore the original before the
|
||||
// apply. The setup lease is a sibling and does not bind the root entry.
|
||||
//
|
||||
// The consequence was not merely a wrong ACL. On a later failure, stamp
|
||||
// compensation compared the object it found against the snapshot's identity,
|
||||
// refused to restore, and left this run's stamp on a directory whose marker
|
||||
// still described the previous successful setup: a failed setup invalidating a
|
||||
// good one.
|
||||
RootIdentity string
|
||||
RootIdentified bool
|
||||
}
|
||||
|
||||
// windowsACLStampSwapHook fires in the exact window this design closes: after
|
||||
// the capability ACE is on the object and before the stamp is written. Nil in
|
||||
// production; a test uses it to replace the runtime root with an ordinary
|
||||
// directory, which is what a local process would do.
|
||||
var windowsACLStampSwapHook func(path string)
|
||||
|
||||
// windowsACLStampWriteHook replaces the ride-along stamp write. Nil in
|
||||
// production; a test uses it to reach the post-commit failure path, which no
|
||||
// ordinary input produces once the bound handle is already open.
|
||||
var windowsACLStampWriteHook func(path string) error
|
||||
|
||||
// verifyStampRootIdentity refuses when the object this apply holds is not the one
|
||||
// the snapshot read.
|
||||
//
|
||||
// windowsACLStampIdentitySwapHook exists so a test can substitute the directory
|
||||
// in exactly the interval between the two opens, which is otherwise unreachable
|
||||
// from any ordinary input.
|
||||
func verifyStampRootIdentity(handle windows.Handle, path string, stamp *windowsACLStampRequest) error {
|
||||
if !stamp.RootIdentified {
|
||||
return fmt.Errorf("the sandbox runtime root %s could not be identified when its prior state was recorded, so this setup cannot prove it is about to change the same directory", path)
|
||||
}
|
||||
identity, err := handleRuntimeIdentity(handle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("identify the sandbox runtime root %s before applying its ACL: %w", path, err)
|
||||
}
|
||||
if identity != stamp.RootIdentity {
|
||||
return fmt.Errorf("the sandbox runtime root %s is no longer the directory this run recorded the prior state of, so applying the ACL and stamp here would attest to an object nobody inspected", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsACLStampIdentitySwapHook fires in the interval between the snapshot's
|
||||
// close and the apply's open, which is where a substitution can actually land.
|
||||
// Nil in production.
|
||||
var windowsACLStampIdentitySwapHook func(path string)
|
||||
|
||||
// windowsSameRuntimeRootPath compares two runtime roots the way the filesystem
|
||||
// does, so the stamp rides along with the right target regardless of spelling.
|
||||
// Beside its only caller: in the shared file it was dead code on every other
|
||||
// platform.
|
||||
func windowsSameRuntimeRootPath(left, right string) bool {
|
||||
left = filepath.Clean(strings.TrimSpace(left))
|
||||
right = filepath.Clean(strings.TrimSpace(right))
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
|
||||
// writeRidingStamp writes the stamp through the handle the capability ACE was
|
||||
// applied on, or through the test hook when one is installed.
|
||||
func writeRidingStamp(handle windows.Handle, path string, planHash string) error {
|
||||
if windowsACLStampWriteHook != nil {
|
||||
return windowsACLStampWriteHook(path)
|
||||
}
|
||||
return writeWindowsRuntimeStampToDirectoryHandle(handle, windowsSandboxRuntimeStampName(planHash), planHash)
|
||||
}
|
||||
|
||||
// restoreWindowsACLThroughHandle puts a captured DACL back on the object the
|
||||
// handle names, by handle rather than by pathname for the same reason the stamp
|
||||
// rides along: after the apply, the name is no longer proof of the object.
|
||||
func restoreWindowsACLThroughHandle(handle windows.Handle, descriptor *windows.SECURITY_DESCRIPTOR) error {
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the captured windows DACL: %w", err)
|
||||
}
|
||||
return windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
|
||||
}
|
||||
|
||||
func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) {
|
||||
return applyWindowsACLPlanWithStamp(plan, nil)
|
||||
}
|
||||
|
||||
func applyWindowsACLPlanWithStamp(plan WindowsACLPlan, stamp *windowsACLStampRequest) (func() error, error) {
|
||||
groups := groupWindowsACLPlanByPath(plan)
|
||||
snapshots := make([]windowsACLSnapshot, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
snapshot, applied, err := applyWindowsACLPathGroupWithStamp(group, stamp)
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
rollbackErr := rollbackWindowsACLSnapshots(snapshots)
|
||||
if rollbackErr != nil {
|
||||
@@ -238,10 +90,6 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
|
||||
}
|
||||
|
||||
func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bool, error) {
|
||||
return applyWindowsACLPathGroupWithStamp(group, nil)
|
||||
}
|
||||
|
||||
func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windowsACLStampRequest) (windowsACLSnapshot, bool, error) {
|
||||
path := strings.TrimSpace(group.Path)
|
||||
if path == "" || len(group.Entries) == 0 {
|
||||
return windowsACLSnapshot{}, false, nil
|
||||
@@ -253,13 +101,6 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
|
||||
// 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).
|
||||
// THE INTERVAL IS HERE, BEFORE THIS OPEN. The snapshot read its object through
|
||||
// its own handle and closed it; a handle already open cannot be renamed out
|
||||
// from under itself, so the only place a substitution can land is between that
|
||||
// close and this open. The hook exists so a test can put it exactly there.
|
||||
if windowsACLStampIdentitySwapHook != nil && stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) {
|
||||
windowsACLStampIdentitySwapHook(path)
|
||||
}
|
||||
materialized := false
|
||||
handle, isDir, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
@@ -328,18 +169,6 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
|
||||
} else {
|
||||
nextDACL = baseDACL
|
||||
}
|
||||
// BEFORE THE FIRST MUTATION, NOT BEFORE THE STAMP. Checking later would leave
|
||||
// the ACL already applied to the wrong object, which is the change that
|
||||
// matters most.
|
||||
//
|
||||
// Fails CLOSED. An unestablished identity refuses rather than passing, because
|
||||
// "we could not tell" is exactly the case this exists for; treating it as
|
||||
// permission would make the guard a no-op precisely when it is needed.
|
||||
if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) {
|
||||
if err := verifyStampRootIdentity(handle, path, stamp); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, nextDACL, nil); err != nil {
|
||||
return fail(fmt.Errorf("apply windows ACL for %s: %w", path, err))
|
||||
}
|
||||
@@ -347,33 +176,8 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
|
||||
// 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.
|
||||
// BEFORE THE HANDLE CLOSES, and only for the target the stamp names. This is
|
||||
// the whole point: the ACE and the stamp land on one kernel object with no
|
||||
// pathname resolution in between.
|
||||
if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) {
|
||||
if windowsACLStampSwapHook != nil {
|
||||
windowsACLStampSwapHook(path)
|
||||
}
|
||||
if err := writeRidingStamp(handle, path, stamp.PlanHash); err != nil {
|
||||
// THE ACE AND ITS STAMP ARE ONE TRANSACTION.
|
||||
//
|
||||
// SetSecurityInfo above has already committed, and this function
|
||||
// returns no rollback closure on its error paths, so the caller has
|
||||
// nothing to compensate with. Without this restore a failed setup
|
||||
// reports failure while leaving the capability grant on a pre-existing
|
||||
// runtime root: the tree stays writable by the restricted token and
|
||||
// nothing on disk records that it should not be.
|
||||
if restoreErr := restoreWindowsACLThroughHandle(handle, descriptor); restoreErr != nil {
|
||||
return fail(fmt.Errorf("stamp windows ACL target %s: %w (the committed ACL could not be restored either: %v)", path, err, restoreErr))
|
||||
}
|
||||
return fail(fmt.Errorf("stamp windows ACL target %s: %w", path, err))
|
||||
}
|
||||
}
|
||||
// Captured while the handle is still open, because this is the last moment
|
||||
// the object and the name are known to be the same thing.
|
||||
identity := windowsIdentityFromHandle(handle)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized, Identity: identity}, true, nil
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil
|
||||
}
|
||||
|
||||
// openWindowsACLTarget opens path for reading and rewriting its DACL without
|
||||
@@ -385,24 +189,6 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
|
||||
// is exactly the redirection this guard exists to prevent. A missing target is
|
||||
// surfaced as os.ErrNotExist so the caller's materialize path still fires.
|
||||
func openWindowsACLTarget(path string) (windows.Handle, bool, error) {
|
||||
// A RUNTIME ROOT IS OPENED BY HANDLE, NOT BY NAME.
|
||||
//
|
||||
// FILE_FLAG_OPEN_REPARSE_POINT below protects only the FINAL component; every
|
||||
// ancestor in the pathname is resolved normally. The runtime tail is the one
|
||||
// part of the tree Zero creates and therefore the one part an unprivileged
|
||||
// local user can predict and pre-empt, and junctions need no privilege, so a
|
||||
// swap at an owned ancestor between the last check and this open redirects the
|
||||
// elevated capability ACL into a directory of their choosing.
|
||||
//
|
||||
// Everything else here is the user's own tree, where an ancestor reparse point
|
||||
// is ordinary configuration and following it is correct.
|
||||
if _, _, owned := windowsSandboxRuntimeOwnedTail(path); owned {
|
||||
handle, err := openWindowsRuntimeTailDirectory(path, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_TRAVERSE)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return handle, true, nil
|
||||
}
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("encode windows ACL target %s: %w", path, err)
|
||||
@@ -816,76 +602,27 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
|
||||
var errs []error
|
||||
for index := len(snapshots) - 1; index >= 0; index-- {
|
||||
snapshot := snapshots[index]
|
||||
// A NAME IS NOT AN OBJECT ONCE THE APPLY HANDLE HAS CLOSED.
|
||||
//
|
||||
// The forward apply and its stamp go through one handle, so they are
|
||||
// provably about one object. Compensation runs later, after a network or
|
||||
// marker failure, and resolves these names again. Opening no-follow stops
|
||||
// a reparse point but accepts an ORDINARY directory moved into the name
|
||||
// since: the original is renamed aside, a substitute is created, and
|
||||
// rollback then restores the pre-apply DACL onto the substitute, strips a
|
||||
// stamp there, and reports success, while the moved original keeps this
|
||||
// run's capability ACE and a valid stamp. Setup would claim a completed
|
||||
// rollback with the modified object still reachable elsewhere, having
|
||||
// also mutated something it never touched going forward.
|
||||
//
|
||||
// So every compensation proves it holds the object it changed, and
|
||||
// otherwise leaves the substitute alone and says plainly what was left
|
||||
// behind.
|
||||
if snapshot.Materialized {
|
||||
// NOT identity-checked, and the reason is a real limit rather than an
|
||||
// oversight. A materialized target is one this run created, and its
|
||||
// plan routinely denies Everyone read (that is what a protected
|
||||
// metadata carve-out IS), so the attributes identity needs cannot be
|
||||
// read back even by the owner: the check turned rollback of every
|
||||
// materialized directory into "Access is denied". Establishing
|
||||
// identity here would mean holding the apply handle open until the
|
||||
// last failure point, which is a larger change than this one.
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// ONE OPEN, AND THE IDENTITY COMES FROM THE HANDLE THAT GETS MUTATED.
|
||||
//
|
||||
// Checking identity through a separate open and then resolving the name
|
||||
// again for the restore proves nothing about the second handle: the two
|
||||
// opens are a check-then-use, and the fact established (this NAME resolved
|
||||
// to the object we changed) is not the fact the write depends on (this
|
||||
// HANDLE is that object). Opening once and asking the handle who it is
|
||||
// removes the window rather than narrowing it.
|
||||
dacl, _, err := snapshot.Descriptor.DACL()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err))
|
||||
continue
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// TWO DIFFERENT REFUSALS. A snapshot whose identity was never established
|
||||
// fails the match below exactly as a replaced target does, and both must
|
||||
// leave the object alone, but telling the operator it was replaced when it
|
||||
// was never identified sends them looking for a substitution that did not
|
||||
// happen. Reported by CodeRabbit.
|
||||
if !snapshot.Identity.valid {
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf(
|
||||
"windows ACL target %s could not be identified when this setup changed it, "+
|
||||
"so rollback cannot prove it is the same object and leaves it with this run's grant",
|
||||
snapshot.Path))
|
||||
continue
|
||||
}
|
||||
if !snapshot.Identity.matches(windowsIdentityFromHandle(handle)) {
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf(
|
||||
"windows ACL target %s is no longer the object this setup modified; "+
|
||||
"leaving the replacement untouched, and the original still carries this run's grant",
|
||||
snapshot.Path))
|
||||
continue
|
||||
}
|
||||
dacl, _, err := snapshot.Descriptor.DACL()
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err))
|
||||
continue
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -48,63 +48,6 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both refusals leave the target alone, but they are different facts: one
|
||||
// target was never identified, the other was replaced. The operator has to be
|
||||
// told which, or a missing identity reads as a substitution that never happened.
|
||||
func TestRollbackTellsAnUnidentifiedTargetFromAReplacedOne(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
group := windowsACLPathGroup{
|
||||
Path: dir,
|
||||
Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLAllowWrite,
|
||||
Path: dir,
|
||||
Capability: "S-1-1-0",
|
||||
}},
|
||||
}
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("apply: applied=%v err=%v", applied, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
|
||||
t.Errorf("restore: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
other := t.TempDir()
|
||||
handle, _, err := openWindowsACLTarget(other)
|
||||
if err != nil {
|
||||
t.Fatalf("open the other directory: %v", err)
|
||||
}
|
||||
otherIdentity := windowsIdentityFromHandle(handle)
|
||||
_ = windows.CloseHandle(handle)
|
||||
if !otherIdentity.valid {
|
||||
t.Fatal("SETUP INVALID: could not identify the other directory")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
identity windowsObjectIdentity
|
||||
want string
|
||||
notWant string
|
||||
}{
|
||||
{"never identified", windowsObjectIdentity{}, "could not be identified", "no longer the object"},
|
||||
{"replaced", otherIdentity, "no longer the object", "could not be identified"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
changed := snapshot
|
||||
changed.Identity = tc.identity
|
||||
err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{changed})
|
||||
if err == nil {
|
||||
t.Fatal("rollback acted on a target it could not prove was the one it changed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), tc.notWant) {
|
||||
t.Fatalf("rollback error = %q, want it to say %q and not %q", err, tc.want, tc.notWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dirDeniesReadSID reports whether path's DACL has a DENY ACE for wantSID whose
|
||||
// mask covers FILE_GENERIC_READ (DenyRead shape) without the full write-probe
|
||||
// mask of experimental DenyWrite.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// windowsACLPlanApplied reports whether the objects a plan names still carry the
|
||||
// grants it describes.
|
||||
//
|
||||
// Off Windows there is no DACL to read and nothing that consumes one, so the
|
||||
// marker's own comparisons are the whole answer. Declared here rather than
|
||||
// guarded at the call site so the setup-to-command contract keeps one shape on
|
||||
// every platform.
|
||||
var windowsACLPlanApplied = func(WindowsACLPlan) bool { return true }
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// windowsACLPlanApplied reads the real security descriptors. See
|
||||
// windowsACLPlanStillApplied.
|
||||
var windowsACLPlanApplied = windowsACLPlanStillApplied
|
||||
@@ -1,207 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsACLPlanStillApplied reports whether the objects a plan names still
|
||||
// carry the grants it describes.
|
||||
//
|
||||
// THE MARKER FINGERPRINTS A PLAN, NOT AN OBJECT. Its hash is computed from
|
||||
// pathnames and entries, so it records what SHOULD be granted and can never
|
||||
// establish that whatever directory currently answers to that name has received
|
||||
// it. The runtime root makes the difference reachable rather than theoretical:
|
||||
// it is deterministic and disposable, so cleanup removes the tree, the next
|
||||
// command's parent recreates the same pathname with ordinary inherited
|
||||
// permissions, and the plan hash is unchanged. The fast path then skipped the
|
||||
// apply entirely and the WRITE_RESTRICTED child could not write TMP or its
|
||||
// language and package caches, with nothing failing to say why.
|
||||
//
|
||||
// PRESENCE OF THE SID IS NOT THE CONTRACT. What the child needs is the grant
|
||||
// windowsACLAccess actually creates, and on a directory it needs to reach
|
||||
// descendants. An ACE that still names the capability SID but has been reduced
|
||||
// to a metadata or read-only mask, or that no longer propagates, leaves a
|
||||
// runtime root that attests as healthy and returns ACCESS_DENIED on the first
|
||||
// write into TMP or a cache: the same silent unusable runtime the attestation
|
||||
// exists to eliminate. So the check compares the effective grant.
|
||||
//
|
||||
// Attesting costs one security-descriptor read per allow entry, on a path that
|
||||
// is about to create a process, and it covers every reason a grant can be
|
||||
// missing or insufficient rather than only recreation by the parent.
|
||||
//
|
||||
// Anything unprovable reads as "not applied". Re-applying an adequate grant is
|
||||
// idempotent and cheap; skipping an inadequate one produces a sandbox that
|
||||
// silently cannot write.
|
||||
func windowsACLPlanStillApplied(plan WindowsACLPlan) bool {
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action != WindowsACLAllowWrite {
|
||||
// Only the allow grants decide whether the child can run. A missing
|
||||
// DENY is a weaker boundary rather than a broken one, and re-applying
|
||||
// the whole plan is what fixes either.
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(entry.Path) == "" || strings.TrimSpace(entry.Capability) == "" {
|
||||
continue
|
||||
}
|
||||
_, required, err := windowsACLAccess(entry.Action)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !windowsPathCarriesGrant(entry.Path, entry.Capability, required) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// windowsPathCarriesGrant reports whether path's DACL gives trustee at least
|
||||
// required, and whether that grant reaches descendants when path is a directory.
|
||||
func windowsPathCarriesGrant(path, trustee string, required windows.ACCESS_MASK) bool {
|
||||
wanted, err := windows.StringToSid(trustee)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// windowsExplicitAccessEntries sets SUB_CONTAINERS_AND_OBJECTS_INHERIT on a
|
||||
// directory, and that is what lets the child create TMP and cache entries
|
||||
// underneath. A grant on the directory alone would not.
|
||||
needInherit := uint8(0)
|
||||
if info.IsDir() {
|
||||
needInherit = windows.CONTAINER_INHERIT_ACE | windows.OBJECT_INHERIT_ACE
|
||||
}
|
||||
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil || dacl == nil {
|
||||
return false
|
||||
}
|
||||
return windowsDACLCarriesGrant(dacl, wanted, required, needInherit)
|
||||
}
|
||||
|
||||
// windowsPlainACEHeaderSize is the part of an ACCESS_ALLOWED_ACE or
|
||||
// ACCESS_DENIED_ACE in front of its SID: the ACE header and the access mask.
|
||||
const windowsPlainACEHeaderSize = 8
|
||||
|
||||
// windowsMinimalSIDSize is a SID with no sub-authorities: revision, count and
|
||||
// the six-byte identifier authority.
|
||||
const windowsMinimalSIDSize = 8
|
||||
|
||||
// windowsPlainACESID returns the SID of a plain allow or deny ACE, or nil when
|
||||
// the entry is not one or cannot be shown to hold a whole SID.
|
||||
//
|
||||
// THE TYPE DECIDES THE LAYOUT, SO IT IS READ FIRST. Only ACCESS_ALLOWED_ACE and
|
||||
// ACCESS_DENIED_ACE keep their SID directly behind the mask. An object ACE puts
|
||||
// a flags word and up to two GUIDs there, and the loop below used to cast every
|
||||
// entry to the plain layout and hand whatever sat at that offset to EqualSid,
|
||||
// whose behaviour is undefined for a structure that is not a SID. A sub-authority
|
||||
// count read out of a flags word is a length nobody vouched for. The entry is
|
||||
// now admitted on its header alone, before any byte past the header is touched.
|
||||
//
|
||||
// Entries in any other layout are skipped, which is the conservative reading for
|
||||
// this attestation: an allow that is not read is not credited, so the worst case
|
||||
// is one idempotent re-apply. A conditional or object DENY is not evaluated
|
||||
// either. Setup never writes one, and nothing else has a reason to name a
|
||||
// capability SID Zero minted.
|
||||
//
|
||||
// The size test is the same rule one level down. AceSize is what the ACL itself
|
||||
// says the entry occupies, so a SID whose own count claims more than that is not
|
||||
// a SID this function can stand behind, and unprovable reads as not applied.
|
||||
// Reported by @jatmn.
|
||||
func windowsPlainACESID(header *windows.ACE_HEADER) (*windows.ACCESS_ALLOWED_ACE, *windows.SID) {
|
||||
if header == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE && header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
|
||||
return nil, nil
|
||||
}
|
||||
if uintptr(header.AceSize) < windowsPlainACEHeaderSize+windowsMinimalSIDSize {
|
||||
return nil, nil
|
||||
}
|
||||
ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header))
|
||||
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
// The count is the second byte of a SID, inside the eight bytes just shown to
|
||||
// be there.
|
||||
subAuthorities := uintptr(*(*byte)(unsafe.Add(unsafe.Pointer(sid), 1)))
|
||||
if uintptr(header.AceSize) < windowsPlainACEHeaderSize+windowsMinimalSIDSize+4*subAuthorities {
|
||||
return nil, nil
|
||||
}
|
||||
return ace, sid
|
||||
}
|
||||
|
||||
// windowsDACLCarriesGrant is the decision windowsPathCarriesGrant makes once it
|
||||
// has the DACL in hand. It is separate so the rule about which entries may be
|
||||
// read can be tested against an ACL built in memory, including layouts a
|
||||
// filesystem would not store.
|
||||
func windowsDACLCarriesGrant(dacl *windows.ACL, wanted *windows.SID, required windows.ACCESS_MASK, needInherit uint8) bool {
|
||||
if dacl == nil || wanted == nil {
|
||||
return false
|
||||
}
|
||||
var granted windows.ACCESS_MASK
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var header *windows.ACE_HEADER
|
||||
if err := windows.GetAce(dacl, index, (**windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&header))); err != nil {
|
||||
return false
|
||||
}
|
||||
ace, sid := windowsPlainACESID(header)
|
||||
if ace == nil {
|
||||
continue
|
||||
}
|
||||
if !sid.Equals(wanted) {
|
||||
continue
|
||||
}
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_DENIED_ACE_TYPE:
|
||||
// A deny naming the capability itself takes precedence over any allow,
|
||||
// so the grant cannot be proven adequate. Fail closed and re-apply.
|
||||
if ace.Mask&required != 0 {
|
||||
return false
|
||||
}
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE:
|
||||
// INHERIT_ONLY DOES NOT APPLY TO THE OBJECT ITSELF. An ACE carrying it
|
||||
// grants descendants and grants the directory nothing, so the child can
|
||||
// still be refused FILE_ADD_FILE on the runtime root while every other
|
||||
// bit here looks satisfied. Checking the inherit flags alone would take
|
||||
// the propagation half of the grant as evidence for the whole of it,
|
||||
// which is the substitution this attestation exists to stop making.
|
||||
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||
continue
|
||||
}
|
||||
// AND PROPAGATION THAT STOPS AT THE IMMEDIATE CHILDREN IS NOT PROPAGATION.
|
||||
//
|
||||
// NO_PROPAGATE_INHERIT_ACE lets an ACE be inherited once and then stops:
|
||||
// the children get it, their children do not. The flag test below reads
|
||||
// only the two inherit bits, so OI|CI|NP satisfied it and the whole mask
|
||||
// was credited, while the runtime tree's real consumers sit a level
|
||||
// deeper - cache/npm, cache/go-build, data/go-mod, and the package files
|
||||
// under them, which sandboxRuntimeEnvironment points npm_config_cache,
|
||||
// GOCACHE and GOMODCACHE at. Attestation said the grant was adequate and
|
||||
// the writes were refused. Reported by @jatmn.
|
||||
//
|
||||
// Gated on needInherit so a file entry, where inherit flags mean nothing,
|
||||
// is unaffected. The apply path never sets NP for an AllowWrite entry, so
|
||||
// this cannot make a grant Zero itself wrote look insufficient.
|
||||
if needInherit != 0 && ace.Header.AceFlags&windows.NO_PROPAGATE_INHERIT_ACE != 0 {
|
||||
continue
|
||||
}
|
||||
// And only ACEs that propagate count towards a directory's grant, since a
|
||||
// non-inheriting one leaves descendants ungranted.
|
||||
if ace.Header.AceFlags&needInherit != needInherit {
|
||||
continue
|
||||
}
|
||||
granted |= ace.Mask
|
||||
}
|
||||
}
|
||||
return granted&required == required
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// A PLAN HASH ATTESTS A PATHNAME, NOT THE DIRECTORY THAT ANSWERS TO IT.
|
||||
//
|
||||
// The unelevated marker records the plan hash and its entry count, both derived
|
||||
// from pathnames and entries. The runtime root is deterministic and disposable,
|
||||
// so cleanup can remove the tree and the next command's parent recreates the
|
||||
// same pathname with ordinary inherited permissions. The hash is unchanged, the
|
||||
// marker still claims the plan was applied, and the replacement never received
|
||||
// the capability ACE, leaving the WRITE_RESTRICTED child unable to write TMP or
|
||||
// its caches with nothing failing to say why.
|
||||
func TestPlanAttestationFailsAfterTheRootIsRecreated(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Guests: a well-known SID that no ordinary object carries, standing in for
|
||||
// the sandbox capability SID.
|
||||
const capability = "S-1-5-32-546"
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: capability},
|
||||
}}
|
||||
|
||||
if windowsACLPlanStillApplied(plan) {
|
||||
t.Fatal("SETUP INVALID: the grant is reported as present before it was applied")
|
||||
}
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rollback() })
|
||||
|
||||
if !windowsACLPlanStillApplied(plan) {
|
||||
t.Fatal("the grant was just applied and the attestation does not see it")
|
||||
}
|
||||
|
||||
// Exactly what cleanup plus the next command's parent does: same pathname,
|
||||
// new directory object, ordinary inherited permissions.
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if windowsACLPlanStillApplied(plan) {
|
||||
t.Error("a recreated root is reported as still carrying the grant, so the apply would be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// A missing path is not a grant either, and must not be read as one.
|
||||
func TestPlanAttestationFailsWhenThePathIsGone(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "absent")
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"},
|
||||
}}
|
||||
if windowsACLPlanStillApplied(plan) {
|
||||
t.Error("a path that does not exist was reported as carrying its grant")
|
||||
}
|
||||
}
|
||||
|
||||
// Deny entries are not load-bearing for the child's ability to run, so their
|
||||
// absence must not force a re-apply on every command.
|
||||
func TestPlanAttestationIgnoresDenyEntries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLDenyWrite, Path: root, Capability: "S-1-5-32-546"},
|
||||
}}
|
||||
if !windowsACLPlanStillApplied(plan) {
|
||||
t.Error("a plan of deny entries alone reported as unapplied, which would re-apply on every command")
|
||||
}
|
||||
}
|
||||
|
||||
// setCapabilityACE replaces path's DACL with a single allow entry for trustee,
|
||||
// so a test can weaken a grant without removing the SID that names it.
|
||||
func setCapabilityACE(t *testing.T, path, trustee string, mask windows.ACCESS_MASK, inheritance uint32) {
|
||||
t.Helper()
|
||||
sid, err := windows.StringToSid(trustee)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dacl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: mask,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: inheritance,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_GROUP,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||
},
|
||||
}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// PRESENCE OF THE SID IS NOT THE CONTRACT.
|
||||
//
|
||||
// What the restricted child needs is the grant windowsACLAccess creates, and on
|
||||
// a directory it needs to reach descendants. An ACE that still names the
|
||||
// capability but has been reduced to a read-only mask, or that no longer
|
||||
// propagates, leaves a runtime root that attests as healthy and then returns
|
||||
// ACCESS_DENIED on the first write into TMP or a cache: exactly the silent
|
||||
// unusable runtime the attestation exists to eliminate.
|
||||
func TestPlanAttestationRejectsAWeakenedCapabilityACE(t *testing.T) {
|
||||
const capability = "S-1-5-32-546"
|
||||
full := windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mask windows.ACCESS_MASK
|
||||
inheritance uint32
|
||||
want bool
|
||||
}{
|
||||
{"the grant the plan describes", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, true},
|
||||
{"reduced to read and execute", windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE), windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false},
|
||||
{"reduced to metadata only", windows.FILE_READ_ATTRIBUTES, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false},
|
||||
{"full mask that does not propagate", full, windows.NO_INHERITANCE, false},
|
||||
{"containers only, so files are ungranted", full, windows.SUB_CONTAINERS_ONLY_INHERIT, false},
|
||||
// INHERIT_ONLY grants descendants and grants the directory nothing, so the
|
||||
// child is refused FILE_ADD_FILE on the runtime root while every inherit
|
||||
// flag the check looks for is present.
|
||||
{"granted to descendants but not to the directory", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT | windows.INHERIT_ONLY_ACE, false},
|
||||
// NO_PROPAGATE lets the ACE be inherited exactly once. The immediate
|
||||
// children are granted and their children are not, so the runtime tree's
|
||||
// real consumers - cache/npm, cache/go-build, data/go-mod - sit past the
|
||||
// end of the grant while both inherit bits the check reads are present.
|
||||
{"propagates only to the immediate children", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT | windows.NO_PROPAGATE_INHERIT_ACE, false},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: capability},
|
||||
}}
|
||||
setCapabilityACE(t, root, capability, testCase.mask, testCase.inheritance)
|
||||
|
||||
if got := windowsACLPlanStillApplied(plan); got != testCase.want {
|
||||
t.Errorf("attestation = %v, want %v; a capability SID is present either way, so only the effective grant separates these",
|
||||
got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// COMPENSATION MUST HOLD THE OBJECT IT CHANGED, NOT THE NAME IT USED.
|
||||
//
|
||||
// The forward apply and its stamp go through one handle, so they are provably
|
||||
// about one object. Compensation runs later, after a network or marker failure,
|
||||
// and resolves those names again. Opening no-follow refuses a reparse point but
|
||||
// accepts an ORDINARY directory moved into the name since the handle closed. So
|
||||
// a rename plus a substitute made rollback restore the pre-apply DACL onto the
|
||||
// substitute and report success, while the moved original kept this run's
|
||||
// capability ACE: a completed rollback with the modified object still reachable
|
||||
// elsewhere, and a directory mutated that the forward operation never touched.
|
||||
func TestRollbackRefusesASubstituteAndReportsTheOriginal(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "target")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const capability = "S-1-5-32-546"
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: capability},
|
||||
}}
|
||||
|
||||
rollback, err := applyWindowsACLPlanWithStamp(plan, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlanWithStamp: %v", err)
|
||||
}
|
||||
if !windowsACLPlanStillApplied(plan) {
|
||||
t.Fatal("SETUP INVALID: the grant is not present after a successful apply")
|
||||
}
|
||||
|
||||
// Exactly the swap the compensation cannot see by name: move the object that
|
||||
// was modified aside, and put an ordinary directory where it was.
|
||||
moved := filepath.Join(base, "moved")
|
||||
if err := os.Rename(root, moved); err != nil {
|
||||
t.Skipf("cannot rename the applied target here: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
substituteBefore := daclOf(t, root)
|
||||
|
||||
err = rollback()
|
||||
if err == nil {
|
||||
t.Fatal("rollback reported success against a substitute directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), root) {
|
||||
t.Errorf("the failure does not name the path left in an unrestored state: %v", err)
|
||||
}
|
||||
|
||||
// The substitute must be byte-for-byte the directory the test created.
|
||||
if after := daclOf(t, root); !equalACEs(after, substituteBefore) {
|
||||
t.Errorf("rollback mutated a directory it never modified:\nbefore %v\nafter %v", substituteBefore, after)
|
||||
}
|
||||
|
||||
// And the original is honestly residual: it still carries this run's grant,
|
||||
// which is what the error is telling the operator.
|
||||
movedPlan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: moved, Capability: capability},
|
||||
}}
|
||||
if !windowsACLPlanStillApplied(movedPlan) {
|
||||
t.Error("the moved original lost its grant, so the error over-reported what was left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// And an unswapped rollback still restores, or the guard would have disabled
|
||||
// compensation rather than bounding it.
|
||||
func TestRollbackStillRestoresTheObjectItModified(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "target")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"},
|
||||
}}
|
||||
before := daclOf(t, root)
|
||||
|
||||
rollback, err := applyWindowsACLPlanWithStamp(plan, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlanWithStamp: %v", err)
|
||||
}
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback of an unswapped target failed: %v", err)
|
||||
}
|
||||
if after := daclOf(t, root); !equalACEs(after, before) {
|
||||
t.Errorf("rollback did not restore the original DACL:\nbefore %v\nafter %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func equalACEs(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for index := range a {
|
||||
if a[index] != b[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// daclOf returns the DACL of path as one line per access-control entry.
|
||||
//
|
||||
// NOT the SDDL of the whole descriptor. That string also carries the owner, the
|
||||
// group and the control flags, and none of those are what this test is about:
|
||||
// SetSecurityInfo sets SE_DACL_AUTO_INHERITED when it writes a DACL, and
|
||||
// GetNamedSecurityInfo does not report owner and group identically on every
|
||||
// machine, so comparing full SDDL fails on a difference that grants nobody
|
||||
// anything. The entries are the access, so the entries are what gets compared.
|
||||
func daclOf(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, 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 {
|
||||
t.Fatalf("read the DACL entries of %s: %v", path, err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return nil
|
||||
}
|
||||
entries := make([]string, 0, dacl.AceCount)
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
t.Fatalf("read ACE %d of %s: %v", index, path, err)
|
||||
}
|
||||
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
entries = append(entries, fmt.Sprintf("type=%d flags=%#x mask=%#x sid=%s",
|
||||
ace.Header.AceType, ace.Header.AceFlags, ace.Mask, sid.String()))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// THE ACE AND ITS STAMP ARE ONE TRANSACTION.
|
||||
//
|
||||
// SetSecurityInfo commits before the stamp is written, and the apply returns no
|
||||
// rollback closure on its error paths, so the caller's compensations have
|
||||
// nothing to undo. A failed setup therefore reported failure while leaving the
|
||||
// capability grant on a pre-existing runtime root: the tree stays writable by
|
||||
// the restricted token and nothing on disk records that it should not be.
|
||||
func TestAStampFailureRestoresTheCommittedACL(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime tree: %v", err)
|
||||
}
|
||||
before := daclOf(t, root)
|
||||
|
||||
previous := windowsACLStampWriteHook
|
||||
windowsACLStampWriteHook = func(string) error { return errors.New("disk full") }
|
||||
t.Cleanup(func() { windowsACLStampWriteHook = previous })
|
||||
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"},
|
||||
}}
|
||||
rollback, err := applyWindowsACLPlanWithStamp(plan, stampRequestFor(t, root, "planhash"))
|
||||
if err == nil {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
t.Fatal("the apply reported success even though the stamp could not be written")
|
||||
}
|
||||
if rollback != nil {
|
||||
// A rollback closure here would be the other acceptable shape, but the
|
||||
// caller only receives one on success, so it must not be relied on.
|
||||
_ = rollback()
|
||||
}
|
||||
|
||||
if after := daclOf(t, root); !slices.Equal(after, before) {
|
||||
t.Errorf("the committed capability grant survived a failed setup:\nbefore %s\nafter %s",
|
||||
strings.Join(before, " | "), strings.Join(after, " | "))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash))); err == nil {
|
||||
t.Error("a stamp exists even though the stamp step failed")
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THROUGH THE REAL APPLY, with the swap in the window that matters.
|
||||
//
|
||||
// The direct-handle test cannot distinguish handle from pathname, because any
|
||||
// name derived from the handle resolves back to the same object. What the old
|
||||
// code did was different: it re-opened the ORIGINAL root string after the ACL
|
||||
// step, so a directory swapped in under that name collected the stamp. This
|
||||
// drives applyWindowsACLPlanWithStamp and performs the swap between the ACE
|
||||
// landing and the stamp write.
|
||||
func TestTheStampSkipsADirectorySwappedInAfterTheACE(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime tree: %v", err)
|
||||
}
|
||||
|
||||
moved := root + "-original"
|
||||
previous := windowsACLStampSwapHook
|
||||
windowsACLStampSwapHook = func(path string) {
|
||||
// Ordinary directories throughout. Nothing here is a reparse point, which
|
||||
// is why a no-follow re-open does not catch it.
|
||||
if err := os.Rename(path, moved); err != nil {
|
||||
t.Skipf("cannot rename the runtime root here: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatalf("plant the replacement: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { windowsACLStampSwapHook = previous })
|
||||
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"},
|
||||
}}
|
||||
rollback, err := applyWindowsACLPlanWithStamp(plan, stampRequestFor(t, root, "planhash"))
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlanWithStamp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rollback() })
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash))); err == nil {
|
||||
t.Error("the swapped-in directory collected the stamp; it carries no capability ACE and would still validate as set up")
|
||||
}
|
||||
recorded, err := os.ReadFile(filepath.Join(moved, windowsSandboxRuntimeStampName(testStampPlanHash)))
|
||||
if err != nil {
|
||||
t.Fatalf("the stamp did not land on the object the ACE was applied to: %v", err)
|
||||
}
|
||||
if string(recorded) != "planhash" {
|
||||
t.Errorf("stamp contents = %q, want the plan hash", recorded)
|
||||
}
|
||||
}
|
||||
|
||||
// stampRequestFor builds the request the way runWindowsSandboxSetup does, with
|
||||
// the snapshot's identity carried on it. Building one by hand without that
|
||||
// identity is refused now, and rightly: the apply cannot prove it holds the
|
||||
// object the snapshot read.
|
||||
func stampRequestFor(t *testing.T, root string, planHash string) *windowsACLStampRequest {
|
||||
t.Helper()
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot the runtime stamp for %s: %v", root, err)
|
||||
}
|
||||
return &windowsACLStampRequest{
|
||||
Root: root,
|
||||
PlanHash: planHash,
|
||||
RootIdentity: snapshot.rootIdentity,
|
||||
RootIdentified: snapshot.rootIdentified,
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The positive half only. The swap case that proves the ACE and the stamp are
|
||||
// about ONE OBJECT lives in windows_acl_stamp_swap_windows_test.go, where it can
|
||||
// drive the real apply.
|
||||
//
|
||||
// A direct-handle test cannot prove it: any name derived from the retained
|
||||
// handle resolves back to the same object, so a pathname write derived that way
|
||||
// lands correctly too and the test passes either way. The distinction only shows
|
||||
// through the call site, where the old code re-opened the ORIGINAL root string.
|
||||
// And the ordinary path still works, or the assertion above would be satisfied
|
||||
// by a writer that never writes anything.
|
||||
func TestTheStampWritesThroughAnOpenDirectoryHandle(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime tree: %v", err)
|
||||
}
|
||||
handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windowsFileAddFile|windows.FILE_TRAVERSE)
|
||||
if err != nil {
|
||||
t.Fatalf("open the runtime root: %v", err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
if err := writeWindowsRuntimeStampToDirectoryHandle(handle, windowsSandboxRuntimeStampName(testStampPlanHash), testStampPlanHash); err != nil {
|
||||
t.Fatalf("write the stamp: %v", err)
|
||||
}
|
||||
recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root, testStampPlanHash))
|
||||
if err != nil || string(recorded) != "planhash" {
|
||||
t.Fatalf("the stamp did not land in the runtime root (%q, err %v)", recorded, err)
|
||||
}
|
||||
}
|
||||
@@ -19,18 +19,7 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
}
|
||||
switch config.SandboxLevel {
|
||||
case WindowsSandboxLevelRestrictedToken:
|
||||
setupConfig := WindowsSandboxSetupConfigFromCommand(config)
|
||||
if err := ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// The marker and the stamp are about intent and about the pathname. Whether
|
||||
// the objects still carry the grant is a third question, and it is the one
|
||||
// that decides whether this child can write its temp and cache directories.
|
||||
// The unelevated tier below answers it by reading the descriptors and
|
||||
// re-applying; this tier cannot repeat an elevated provisioning, so it
|
||||
// refuses and names the action.
|
||||
if err := ValidateWindowsSandboxLaunchGrants(setupConfig); err != nil {
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
@@ -128,11 +117,7 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The marker says this plan was applied; the object has to agree. See
|
||||
// windowsACLPlanStillApplied: a deterministic runtime root can be removed and
|
||||
// recreated between commands under the same pathname, which leaves the plan
|
||||
// hash identical and the capability grant gone.
|
||||
if marker.contains(applied) && windowsACLPlanStillApplied(plan) {
|
||||
if marker.contains(applied) {
|
||||
return nil
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE READER MUST BE THE TOKEN THAT VALIDATES, NOT THE ONE THAT INSTALLS.
|
||||
//
|
||||
// Deriving the reader from the runtime leaf's owner looked right and is wrong
|
||||
// across the elevation boundary: the elevated helper CREATES that leaf when it
|
||||
// is absent, so the owner is commonly BUILTIN\Administrators. A later
|
||||
// UAC-filtered administrator carries that group deny-only, and a standard user
|
||||
// given alternate administrator credentials is not in it at all, so the
|
||||
// protected stamp ends up with no enabled allow ACE for the token that has to
|
||||
// read it. Setup reports success and every restricted command then stops before
|
||||
// launch.
|
||||
//
|
||||
// The consumer is therefore resolved in the operator's shell and carried in.
|
||||
// This test pins that the carried identity wins over the leaf owner, which is
|
||||
// the whole difference between the two designs.
|
||||
func TestCarriedConsumerSIDOutranksTheLeafOwner(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime tree: %v", err)
|
||||
}
|
||||
|
||||
// A consumer that is deliberately NOT this process and NOT a repair identity,
|
||||
// so it cannot be confused with the leaf owner or folded into the
|
||||
// SYSTEM/Administrators grants.
|
||||
consumer, err := windows.StringToSid("S-1-5-21-1111111111-2222222222-3333333333-1001")
|
||||
if err != nil {
|
||||
t.Fatalf("build the stand-in consumer SID: %v", err)
|
||||
}
|
||||
restore := setWindowsSetupConsumerSID(consumer)
|
||||
t.Cleanup(restore)
|
||||
|
||||
if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil {
|
||||
t.Fatalf("write the stamp: %v", err)
|
||||
}
|
||||
stamp := windowsSandboxRuntimeStampPath(root, testStampPlanHash)
|
||||
|
||||
mask, present := stampACEMask(t, stamp, consumer)
|
||||
if !present {
|
||||
t.Fatal("the carried consumer has no ACE; the stamp still names whoever owns the leaf")
|
||||
}
|
||||
if mask&windows.FILE_READ_DATA == 0 {
|
||||
t.Errorf("the carried consumer cannot read the stamp (mask 0x%08x)", mask)
|
||||
}
|
||||
// It is not the owner of the leaf, so this also proves the owner fallback did
|
||||
// not silently win.
|
||||
owner := ownerOfDirectory(t, root)
|
||||
if owner.Equals(consumer) {
|
||||
t.Skip("the leaf owner happens to equal the stand-in consumer; this cannot distinguish the two")
|
||||
}
|
||||
if _, ownerPresent := stampACEMask(t, stamp, owner); ownerPresent && !isRepairIdentity(t, owner) {
|
||||
t.Error("the leaf owner was granted alongside the carried consumer")
|
||||
}
|
||||
|
||||
// Repair must still work, and the capability SID must still be absent.
|
||||
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} {
|
||||
sid, err := windows.CreateWellKnownSid(wellKnown)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve well-known SID: %v", err)
|
||||
}
|
||||
if m, ok := stampACEMask(t, stamp, sid); !ok || m&windows.FILE_WRITE_DATA == 0 {
|
||||
t.Errorf("repair identity %s lost write (present=%v mask 0x%08x)", sid, ok, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE STAMP IS NOT THE GRANT.
|
||||
//
|
||||
// The elevated tier attested its runtime root with the marker comparisons and a
|
||||
// stamp file. Those prove that setup's intent matches this command's, and that
|
||||
// the directory was not removed and recreated under the same pathname. An
|
||||
// ordinary file answers the second by existing, and it survives an ACL edit
|
||||
// untouched, so an `icacls /reset`, an inheritance change on a parent, or a
|
||||
// security product rewriting the DACL all leave a valid stamp over a runtime
|
||||
// root the WRITE_RESTRICTED child cannot write. The marker agrees, the stamp
|
||||
// agrees, and the first write into TMP or a package cache returns ACCESS_DENIED
|
||||
// with nothing having said why.
|
||||
//
|
||||
// This is the function runWindowsSandboxCommand calls for the restricted-token
|
||||
// tier, beside the marker validation. The unelevated tier answers the same
|
||||
// question by reading the descriptors and re-applying; this one cannot repeat an
|
||||
// elevated provisioning, so it refuses and names the action.
|
||||
func TestTheLaunchGateRefusesAnUnappliedGrant(t *testing.T) {
|
||||
config := WindowsSandboxSetupConfig{
|
||||
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},
|
||||
},
|
||||
}
|
||||
|
||||
previous := windowsACLPlanApplied
|
||||
t.Cleanup(func() { windowsACLPlanApplied = previous })
|
||||
|
||||
windowsACLPlanApplied = func(WindowsACLPlan) bool { return true }
|
||||
if err := ValidateWindowsSandboxLaunchGrants(config); err != nil {
|
||||
t.Fatalf("SETUP INVALID: the gate refuses even with the grants intact: %v", err)
|
||||
}
|
||||
|
||||
windowsACLPlanApplied = func(WindowsACLPlan) bool { return false }
|
||||
err := ValidateWindowsSandboxLaunchGrants(config)
|
||||
if err == nil {
|
||||
t.Fatal("a runtime root that no longer carries its grants passed the launch gate, so the command starts into a sandbox it cannot write")
|
||||
}
|
||||
// Actionable, and about the right thing: an operator told "permission roots
|
||||
// changed" goes looking at their policy for a problem that is not there.
|
||||
for _, want := range []string{"permissions", "zero sandbox setup"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not mention %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "out of date") {
|
||||
t.Errorf("the refusal reads as a policy edit, which sends the operator to the wrong place: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And the marker comparison stays a marker comparison. Folding the grant check
|
||||
// into it would have made every consumer of the marker, including `zero doctor`,
|
||||
// depend on real security descriptors, which is a different question from the
|
||||
// one that function's name asks.
|
||||
func TestTheMarkerValidationDoesNotReadDescriptors(t *testing.T) {
|
||||
config := WindowsSandboxSetupConfig{
|
||||
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},
|
||||
},
|
||||
}
|
||||
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
previous := windowsACLPlanApplied
|
||||
t.Cleanup(func() { windowsACLPlanApplied = previous })
|
||||
windowsACLPlanApplied = func(WindowsACLPlan) bool { return false }
|
||||
|
||||
if err := ValidateWindowsSandboxSetupMarker(config); err != nil {
|
||||
t.Errorf("the marker comparison consulted the descriptors: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE GATE HAS TO BE ON THE PATH THAT LAUNCHES, NOT ONLY IN A FUNCTION.
|
||||
//
|
||||
// windows_elevated_grant_attest_test.go proves ValidateWindowsSandboxLaunchGrants
|
||||
// answers correctly. It calls it directly, so it stays green if the call in
|
||||
// runWindowsSandboxCommand is deleted, which is exactly how a check that reads
|
||||
// correct becomes a check that never runs. This drives the runner.
|
||||
func TestTheRestrictedTokenTierRefusesBeforeCreatingAToken(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo hi"},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: `C:\workspace`}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
}
|
||||
if _, err := WriteWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
previous := windowsACLPlanApplied
|
||||
t.Cleanup(func() { windowsACLPlanApplied = previous })
|
||||
|
||||
// With the grants intact the tier gets PAST both attestations. It fails later,
|
||||
// on this machine, for reasons that have nothing to do with the gate, so the
|
||||
// assertion is only that the refusal below is not what stopped it.
|
||||
windowsACLPlanApplied = func(WindowsACLPlan) bool { return true }
|
||||
var healthy bytes.Buffer
|
||||
runWindowsSandboxCommand(config, &healthy)
|
||||
if strings.Contains(healthy.String(), "no longer carry the permissions") {
|
||||
t.Fatalf("SETUP INVALID: the gate refused with the grants intact: %s", healthy.String())
|
||||
}
|
||||
|
||||
windowsACLPlanApplied = func(WindowsACLPlan) bool { return false }
|
||||
var stderr bytes.Buffer
|
||||
code := runWindowsSandboxCommand(config, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("the runner launched into a sandbox whose directories no longer carry their grants")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no longer carry the permissions") {
|
||||
t.Errorf("the runner did not refuse for the missing grant; it stopped for something else: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -341,33 +341,11 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
if execRequest.EnforcementLevel == EnforcementUnelevated {
|
||||
level = WindowsSandboxLevelUnelevated
|
||||
}
|
||||
// Derived AND created here, in the caller's shell. The unelevated tier applies
|
||||
// this plan itself, and applyWindowsACLPlan fails the whole run on an AllowWrite
|
||||
// target that does not exist; prepareSandboxRuntime creates only the candidate
|
||||
// this process selects, so the other one has to be created explicitly. The
|
||||
// runner cannot do it, for the same reason it cannot derive them (below).
|
||||
runtimeProfile, err := windowsSandboxProfileWithProvisionedRuntime(
|
||||
execRequest.PermissionProfile,
|
||||
[]string{execRequest.WorkspaceRoot},
|
||||
)
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
// The SAME augmentation setup applied, so the marker's plan and this
|
||||
// command's plan describe the same roots.
|
||||
//
|
||||
// The runner cannot derive the candidates itself: it runs re-exec'd with
|
||||
// TEMP and TMP already pointed at the sandbox runtime temp, so os.TempDir()
|
||||
// inside it returns the redirected value and the temp-derived candidate
|
||||
// comes out rooted under the runtime tree instead of under the real temp.
|
||||
// Setup, whose TEMP is untouched, derived the other spelling, and every
|
||||
// command then died on "permission roots or deny lists changed" with two
|
||||
// plans that had the same number of entries and different paths.
|
||||
PermissionProfile: runtimeProfile,
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
Env: childEnv,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE AUGMENTATION HAS TO HAPPEN ON THE COMMAND PATH, NOT ONLY IN A HELPER.
|
||||
//
|
||||
// windows_setup_runtime_root_test.go proves the pieces compose: given a profile
|
||||
// put through WindowsSandboxProfileWithRuntimeRoots on both sides, the marker
|
||||
// validates. It calls that function directly, so it stays green even when the
|
||||
// production call site in BuildCommandPlan is deleted, which is exactly the
|
||||
// shape of the bug being fixed here. Reverting the runner call and watching that
|
||||
// test still pass is how this gap was found.
|
||||
//
|
||||
// So this drives the real path and asserts on what the runner is actually handed.
|
||||
// The config is serialized into the runner's argv, so the argv is where to look:
|
||||
// recomputing the profile in the test would just be the helper test again.
|
||||
func TestBuildCommandPlanCarriesTheRuntimeRootsIntoTheRunnerArgs(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{
|
||||
Name: BackendWindowsRestrictedToken,
|
||||
Available: true,
|
||||
Platform: "windows",
|
||||
Executable: filepath.Join(t.TempDir(), WindowsSandboxCommandRunnerName),
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
},
|
||||
})
|
||||
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "cmd.exe",
|
||||
Args: WindowsShellArgs("echo hi"),
|
||||
Dir: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
|
||||
candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspace})
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("no runtime candidates derived, so this test would pass vacuously")
|
||||
}
|
||||
|
||||
// The profile reaches the runner as JSON inside one of these arguments.
|
||||
argv := strings.Join(plan.Args, "\x00")
|
||||
for _, candidate := range candidates {
|
||||
// JSON escapes the backslashes in a Windows path, so compare in the same
|
||||
// spelling the encoder produced rather than the raw path.
|
||||
encoded := strings.ReplaceAll(candidate, `\`, `\\`)
|
||||
if !strings.Contains(argv, encoded) && !strings.Contains(argv, candidate) {
|
||||
t.Errorf("the runner argv does not carry runtime root %s; setup grants it, so the plans disagree and every command dies on \"permission roots or deny lists changed\"", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GRANTING A ROOT IS NOT THE SAME AS PROVISIONING IT.
|
||||
//
|
||||
// The unelevated tier applies this plan itself, and applyWindowsACLPlan
|
||||
// materializes only DenyRead targets: an AllowWrite target that does not exist
|
||||
// aborts the run with "windows ACL target does not exist". prepareSandboxRuntime
|
||||
// creates only the candidate the process SELECTS, so the other one has to be
|
||||
// created explicitly, and it has to happen in the parent because the runner runs
|
||||
// with TEMP redirected into the runtime tree and derives a different temp-side
|
||||
// spelling.
|
||||
func TestBuildCommandPlanProvisionsTheRuntimeRootsItGrants(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
tempRoot := t.TempDir()
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
|
||||
candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspace})
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("no runtime candidates derived, so this test would pass vacuously")
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if err := os.RemoveAll(candidate); err != nil {
|
||||
t.Fatalf("clear candidate %s: %v", candidate, err)
|
||||
}
|
||||
}
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{
|
||||
Name: BackendWindowsRestrictedToken,
|
||||
Available: true,
|
||||
Platform: "windows",
|
||||
Executable: filepath.Join(t.TempDir(), WindowsSandboxCommandRunnerName),
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
},
|
||||
})
|
||||
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "cmd.exe",
|
||||
Args: WindowsShellArgs("echo hi"),
|
||||
Dir: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
t.Errorf("runtime root %s is granted by the plan but was not created: %v; the unelevated tier aborts on \"windows ACL target does not exist\"", candidate, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("runtime root %s exists but is not a directory", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AN ELEVATED ACL MUST NOT BE WRITTEN THROUGH A LINK.
|
||||
//
|
||||
// The runtime root is a predictable path under the user cache, and every
|
||||
// component below the cache root is created by us. A local user needs no
|
||||
// privilege to create a junction, so they can plant one at "zero", "runtime" or
|
||||
// "v1" and have provisioning follow it: the leaf is created in their tree
|
||||
// instead of ours, openWindowsACLTarget opens that leaf, sees no reparse point
|
||||
// ON THE LEAF, and elevated setup grants the sandbox capability write access to
|
||||
// a directory the attacker controls.
|
||||
//
|
||||
// The variant that matters is the one where the attacker ALSO creates the
|
||||
// components below the junction. A check that looks only at the deepest existing
|
||||
// component then finds an ordinary directory and passes.
|
||||
func TestProvisioningRefusesAReparsePointAtAnOwnedAncestor(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("junctions are a Windows construct; the guard is only reachable there")
|
||||
}
|
||||
|
||||
// The owned tail, exactly as deterministicSandboxRuntimeRoot joins it.
|
||||
for _, ancestor := range []string{"zero", filepath.Join("zero", "runtime"), filepath.Join("zero", "runtime", "v1")} {
|
||||
t.Run(ancestor, func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
cache := filepath.Join(base, "cache")
|
||||
decoy := filepath.Join(base, "attacker-owned")
|
||||
root := filepath.Join(cache, "zero", "runtime", "v1", "abc123def456")
|
||||
|
||||
link := filepath.Join(cache, ancestor)
|
||||
if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(decoy, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command("cmd", "/c", "mklink", "/J", link, decoy).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("cannot create a junction here: %v %s", err, out)
|
||||
}
|
||||
|
||||
// The attacker fills in everything below the junction, so the deepest
|
||||
// EXISTING component is an ordinary directory and a check that looks only
|
||||
// there is satisfied.
|
||||
below := strings.TrimPrefix(strings.TrimPrefix(filepath.Dir(root), filepath.Join(cache, ancestor)), string(filepath.Separator))
|
||||
if below != "" {
|
||||
if err := os.MkdirAll(filepath.Join(decoy, below), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
created, err := createRuntimeDirRecording(root)
|
||||
if err == nil {
|
||||
physical := physicalSandboxPath(root)
|
||||
t.Fatalf("provisioning followed a junction at %s and created %v (physically %s); an elevated ACL applied to that leaf lands on a directory the attacker controls", ancestor, created, physical)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "link") {
|
||||
t.Errorf("the refusal does not explain that a link was in the way: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case must still provision. A guard that refuses everything would
|
||||
// satisfy the test above and break every real machine.
|
||||
func TestProvisioningStillCreatesAnOrdinaryRuntimeRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "cache", "zero", "runtime", "v1", "abc123def456")
|
||||
created, err := createRuntimeDirRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("createRuntimeDirRecording on a clean tree: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("nothing was recorded as created")
|
||||
}
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
t.Fatalf("the runtime root was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The CACHE ROOT above the owned components is the user's, and a redirected
|
||||
// LOCALAPPDATA legitimately makes it a reparse point. Refusing there would break
|
||||
// ordinary machines, so the guard must stop at the components Zero creates.
|
||||
func TestProvisioningAllowsAReparsePointAboveTheOwnedComponents(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("junctions are a Windows construct")
|
||||
}
|
||||
base := t.TempDir()
|
||||
real := filepath.Join(base, "real-cache")
|
||||
link := filepath.Join(base, "redirected-cache")
|
||||
if err := os.MkdirAll(real, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, real).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v %s", err, out)
|
||||
}
|
||||
|
||||
root := filepath.Join(link, "zero", "runtime", "v1", "abc123def456")
|
||||
if _, err := createRuntimeDirRecording(root); err != nil {
|
||||
t.Errorf("provisioning refused a redirected cache root, which is an ordinary Windows configuration: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LEASE FALLBACK MUST NOT BRICK THE WORKSPACE.
|
||||
//
|
||||
// Setup used to fingerprint a plan built from the cache-derived root, while a
|
||||
// command derived the same root, failed to LEASE it, and silently relocated to
|
||||
// the temp fallback. The command's plan then named a tree setup had never
|
||||
// provisioned, and the marker rejected it with "permission roots or deny lists
|
||||
// changed", which blames permissions for a runtime-root disagreement.
|
||||
//
|
||||
// Re-running setup could not recover: sandboxRuntimeRootFor rejects a candidate
|
||||
// only for landing inside the workspace, never for being unusable, so setup
|
||||
// chose the same unleasable root again. The only escapes were deleting the
|
||||
// marker, which silently drops WFP network enforcement, or turning the sandbox
|
||||
// off. Neither is mentioned by the error.
|
||||
//
|
||||
// Setup and the command select through one function now, so a fallback is
|
||||
// something they agree on rather than something that splits them.
|
||||
func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) {
|
||||
config := runtimeRootTestConfig(t)
|
||||
|
||||
setupRoot, setupLease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot (setup side): %v", err)
|
||||
}
|
||||
setupLease.release()
|
||||
|
||||
commandRoot, commandLease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot (command side): %v", err)
|
||||
}
|
||||
commandLease.release()
|
||||
|
||||
if setupRoot != commandRoot {
|
||||
t.Fatalf("setup selected %s and the command selected %s; the marker cannot validate across that", setupRoot, commandRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// THE MARKER MUST BE ABOUT AN OBJECT, NOT ABOUT A PATHNAME.
|
||||
//
|
||||
// cleanupSandboxRuntimeRoots evicts inactive roots with os.RemoveAll on an age
|
||||
// and count policy, and the next run for that workspace recreates the same
|
||||
// deterministic pathname with ordinary inherited permissions. The plan hash is
|
||||
// over pathnames, so it was unchanged, and both the elevated and the unelevated
|
||||
// marker checks reported setup as current while the recreated directory carried
|
||||
// no capability ACE at all: a WRITE_RESTRICTED token could not write TMP,
|
||||
// GOCACHE or anything else beneath it, with nothing saying why.
|
||||
func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) {
|
||||
config := runtimeRootTestConfig(t)
|
||||
|
||||
selected, lease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
|
||||
setup := WindowsSandboxSetupConfigFromCommand(config)
|
||||
setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(
|
||||
permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: selected}),
|
||||
config.WorkspaceRoots,
|
||||
)
|
||||
if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
command := config
|
||||
command.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(
|
||||
permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}),
|
||||
config.WorkspaceRoots,
|
||||
)
|
||||
// The premise: it validates while the provisioned tree is intact. Without
|
||||
// this the eviction assertion below could pass for the wrong reason.
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)); err != nil {
|
||||
t.Fatalf("SETUP INVALID: the marker did not validate before eviction: %v", err)
|
||||
}
|
||||
|
||||
// Eviction, exactly as cleanupSandboxRuntimeRoots performs it.
|
||||
if err := os.RemoveAll(selected); err != nil {
|
||||
t.Fatalf("evict the runtime root: %v", err)
|
||||
}
|
||||
// And the pathname comes back, as prepareSandboxRuntime recreates it, with
|
||||
// ordinary permissions and no capability ACE. This is the state that used to
|
||||
// validate.
|
||||
if err := os.MkdirAll(selected, 0o700); err != nil {
|
||||
t.Fatalf("recreate the runtime root: %v", err)
|
||||
}
|
||||
|
||||
err = ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command))
|
||||
if err == nil {
|
||||
t.Fatal("the marker still validates after the provisioned tree was evicted and recreated, so the command runs with no capability ACE and nothing reports it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "removed since setup ran") {
|
||||
t.Errorf("the error does not explain that the runtime tree was removed, so the operator cannot act on it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile carrying no runtime root has nothing to check, which is the setup
|
||||
// side itself and every non-restricted profile. The stamp must not become a
|
||||
// requirement where there is no tree.
|
||||
func TestRuntimeStampIsNotRequiredWithoutARuntimeRoot(t *testing.T) {
|
||||
if err := validateWindowsSandboxRuntimeStamp(PermissionProfile{}, "somehash"); err != nil {
|
||||
t.Errorf("a profile carrying no runtime root was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// blockCacheRuntimeRoot makes the cache-derived runtime root unleasable, and
|
||||
// returns the function that frees it again.
|
||||
//
|
||||
// A file where prepareSandboxRuntimeLease wants a directory: MkdirAll on the
|
||||
// parent fails, the lease attempt fails with it, and selection relocates to the
|
||||
// temp fallback. It stands in for any reason the preferred root is unavailable
|
||||
// for a moment, which is the whole point -- the defect never depended on which
|
||||
// reason it was.
|
||||
func blockCacheRuntimeRoot(t *testing.T, workspaceRoot string) (string, func()) {
|
||||
t.Helper()
|
||||
cacheRoot, err := sandboxUserCacheDir()
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no user cache directory: %v", err)
|
||||
}
|
||||
preferred, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspaceRoot), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no cache-derived runtime root: %v", err)
|
||||
}
|
||||
blocker := filepath.Dir(preferred)
|
||||
if err := os.MkdirAll(filepath.Dir(blocker), 0o700); err != nil {
|
||||
t.Fatalf("create the blocker's parent: %v", err)
|
||||
}
|
||||
_ = os.RemoveAll(blocker)
|
||||
if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatalf("block the cache runtime root: %v", err)
|
||||
}
|
||||
return preferred, func() { _ = os.Remove(blocker) }
|
||||
}
|
||||
|
||||
// A TRANSIENT SELECTION IS NOT A DURABLE CONFIGURATION.
|
||||
//
|
||||
// Setup selected a runtime root, released the lease, and recorded a plan hash
|
||||
// over pathnames. A command later ran the SAME selector, and the selector
|
||||
// consults a lease: with the cache root briefly unusable setup provisioned and
|
||||
// recorded the temp fallback, and once it freed up the command selected the
|
||||
// cache root instead. Its plan hash and stamp path then named a tree setup had
|
||||
// never provisioned, so the marker rejected every command with "permission roots
|
||||
// or deny lists changed" -- and re-running setup did not help, because setup was
|
||||
// equally free to pick the other root.
|
||||
//
|
||||
// Setup's choice is recorded now and the command consumes it, so the two cannot
|
||||
// disagree no matter what changes in between.
|
||||
func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) {
|
||||
config := runtimeRootTestConfig(t)
|
||||
workspace := config.WorkspaceRoots[0]
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome)
|
||||
|
||||
preferred, unblock := blockCacheRuntimeRoot(t, workspace)
|
||||
|
||||
// Setup, with the cache root unusable. honorRecorded is false because setup
|
||||
// is the one making the choice.
|
||||
setupRoot, setupLease, _, err := selectSandboxRuntimeRoot(workspace, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err)
|
||||
}
|
||||
setupLease.release()
|
||||
if sameWindowsRuntimeRootPath(setupRoot, preferred) {
|
||||
t.Fatalf("setup selected the cache root %s even though it was blocked; this case is not being exercised", setupRoot)
|
||||
}
|
||||
|
||||
setup := WindowsSandboxSetupConfigFromCommand(config)
|
||||
setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(
|
||||
permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: setupRoot}),
|
||||
config.WorkspaceRoots,
|
||||
)
|
||||
if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
// And now the cache root frees up, which is exactly when the old code
|
||||
// diverged.
|
||||
unblock()
|
||||
|
||||
commandRoot, commandLease, _, err := selectSandboxRuntimeRoot(workspace, true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot (command): %v", err)
|
||||
}
|
||||
commandLease.release()
|
||||
if !sameWindowsRuntimeRootPath(commandRoot, setupRoot) {
|
||||
t.Fatalf("setup provisioned %s and the command selected %s once the cache root freed up; the marker can never validate across that", setupRoot, commandRoot)
|
||||
}
|
||||
|
||||
command := config
|
||||
command.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(
|
||||
permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: commandRoot}),
|
||||
config.WorkspaceRoots,
|
||||
)
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)); err != nil {
|
||||
t.Fatalf("the first command after setup was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The record belongs to the workspace setup ran for. One sandbox home serves
|
||||
// whichever workspace ran setup last, so honouring a root recorded for a
|
||||
// different workspace would point this command's runtime at somebody else's
|
||||
// tree.
|
||||
func TestARootRecordedForAnotherWorkspaceIsNotHonoured(t *testing.T) {
|
||||
config := runtimeRootTestConfig(t)
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome)
|
||||
|
||||
foreign := filepath.Join(t.TempDir(), "somebody-elses-runtime")
|
||||
setup := WindowsSandboxSetupConfigFromCommand(config)
|
||||
setup.PermissionProfile = permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: foreign})
|
||||
if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
selected, lease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
if sameWindowsRuntimeRootPath(selected, foreign) {
|
||||
t.Fatalf("a root recorded for another workspace was honoured: %s", selected)
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded root that cannot be leased must FAIL, not relocate.
|
||||
//
|
||||
// Relocating is what produced the permanent brick: the other root carries no
|
||||
// capability ACE, so the command is rejected anyway, with a message about
|
||||
// permissions that sends the operator looking in the wrong place.
|
||||
func TestAnUnusableRecordedRootFailsInsteadOfRelocating(t *testing.T) {
|
||||
config := runtimeRootTestConfig(t)
|
||||
workspace := config.WorkspaceRoots[0]
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome)
|
||||
|
||||
recorded, lease, _, err := selectSandboxRuntimeRoot(workspace, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err)
|
||||
}
|
||||
lease.release()
|
||||
|
||||
setup := WindowsSandboxSetupConfigFromCommand(config)
|
||||
setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(
|
||||
permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: recorded}),
|
||||
config.WorkspaceRoots,
|
||||
)
|
||||
if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
// Make the provisioned root unusable after the fact.
|
||||
blocker := filepath.Dir(recorded)
|
||||
if err := os.RemoveAll(blocker); err != nil {
|
||||
t.Skipf("cannot displace the recorded root in this environment: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Skipf("cannot displace the recorded root in this environment: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(blocker) })
|
||||
|
||||
selected, selectedLease, _, err := selectSandboxRuntimeRoot(workspace, true, "")
|
||||
if err == nil {
|
||||
selectedLease.release()
|
||||
t.Fatalf("selection relocated to %s instead of reporting that the provisioned root is unusable", selected)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provisioned by setup") || !strings.Contains(err.Error(), "zero sandbox setup") {
|
||||
t.Errorf("the error does not name the situation or the action that fixes it: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A FAILED SETUP LEAVES NO NEW PERSISTENT STATE.
|
||||
//
|
||||
// Runtime roots are materialized before the network plan, the ACL apply, the
|
||||
// network apply and the marker write. Any of those can fail, and the rollback
|
||||
// that existed restored only ACL snapshots, so a run that reported failure still
|
||||
// left new runtime directories behind. It could not have cleaned them up even in
|
||||
// principle, because provisioning returned nothing about what it had made.
|
||||
func TestRuntimeRootProvisioningRecordsOnlyWhatItCreated(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
// A pre-existing ancestor the user owns, and a leaf below it that does not
|
||||
// exist yet. Only the latter may be recorded.
|
||||
existing := filepath.Join(base, "cache", "zero")
|
||||
if err := os.MkdirAll(existing, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(existing, "runtime", "v1", "abc123")
|
||||
|
||||
created, err := createRuntimeDirRecording(target)
|
||||
if err != nil {
|
||||
t.Fatalf("createRuntimeDirRecording: %v", err)
|
||||
}
|
||||
if len(created) != 3 {
|
||||
t.Fatalf("recorded %v, want the three components below the pre-existing ancestor", created)
|
||||
}
|
||||
// Outermost first, so the undo walking backwards removes the leaf before its
|
||||
// parent and never meets a non-empty directory of its own making.
|
||||
want := []string{
|
||||
filepath.Join(existing, "runtime"),
|
||||
filepath.Join(existing, "runtime", "v1"),
|
||||
target,
|
||||
}
|
||||
for index := range want {
|
||||
if created[index].path != want[index] {
|
||||
t.Fatalf("recorded %v, want %v", created, want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("target was not created: %v", err)
|
||||
}
|
||||
|
||||
rollback := windowsRuntimeRootRollback{created: created}
|
||||
if err := rollback.run(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(existing, "runtime")); !os.IsNotExist(err) {
|
||||
t.Errorf("rollback left the created tree behind: %v", err)
|
||||
}
|
||||
// The pre-existing ancestor is not ours and must survive.
|
||||
if _, err := os.Stat(existing); err != nil {
|
||||
t.Errorf("rollback removed a directory it did not create: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Provisioning that finds everything already there records nothing, so a failed
|
||||
// setup on a machine that was already set up removes none of it.
|
||||
func TestRuntimeRootProvisioningRecordsNothingWhenAlreadyPresent(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abc123")
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := createRuntimeDirRecording(target)
|
||||
if err != nil {
|
||||
t.Fatalf("createRuntimeDirRecording: %v", err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Fatalf("recorded %v for a tree that already existed", created)
|
||||
}
|
||||
if err := (windowsRuntimeRootRollback{created: created}).run(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Errorf("rollback removed a pre-existing tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback refuses rather than destroys. A directory that has gained content is
|
||||
// holding something this run did not create, and RemoveAll there would turn a
|
||||
// failed setup into data loss.
|
||||
func TestRuntimeRootRollbackRefusesToRemoveANonEmptyDirectory(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abc123")
|
||||
created, err := createRuntimeDirRecording(target)
|
||||
if err != nil {
|
||||
t.Fatalf("createRuntimeDirRecording: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "someone-elses.txt"), []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (windowsRuntimeRootRollback{created: created}).run(); err == nil {
|
||||
t.Error("rollback reported success while a non-empty directory remained")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "someone-elses.txt")); err != nil {
|
||||
t.Errorf("rollback destroyed content it did not create: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// windowsSandboxRuntimeOwnedNames are the fixed components Zero joins under the
|
||||
// cache or temp root, above the per-workspace digest.
|
||||
//
|
||||
// ONE INVENTORY. deterministicSandboxRuntimeRoot builds a root from these and
|
||||
// the traversal below recognizes one by them, and the two have to stay the same
|
||||
// list or the traversal silently stops treating a real runtime root as owned:
|
||||
// it would fall back to opening by name, which is exactly the unprotected path
|
||||
// this file exists to replace. A wrong answer here fails open, so the two uses
|
||||
// read from the same place.
|
||||
var windowsSandboxRuntimeOwnedNames = []string{"zero", "runtime", "v1"}
|
||||
|
||||
// windowsSandboxRuntimeOwnedDepth is how many trailing components of a runtime
|
||||
// root Zero creates and therefore owns: the fixed names plus the digest.
|
||||
var windowsSandboxRuntimeOwnedDepth = len(windowsSandboxRuntimeOwnedNames) + 1
|
||||
|
||||
// windowsSandboxRuntimeOwnedTail splits a runtime root into the ancestor that
|
||||
// belongs to the user and the components Zero created.
|
||||
//
|
||||
// The base is deliberately not our business. On a machine with a redirected
|
||||
// LOCALAPPDATA it is legitimately a reparse point, and refusing there would
|
||||
// break ordinary setups. Everything below it was created by us and has no
|
||||
// business being a link.
|
||||
//
|
||||
// ok is false when the path does not have the shape a runtime root has, which
|
||||
// means the caller must not treat it as owned.
|
||||
func windowsSandboxRuntimeOwnedTail(root string) (string, []string, bool) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(root))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return "", nil, false
|
||||
}
|
||||
components := make([]string, 0, windowsSandboxRuntimeOwnedDepth)
|
||||
current := cleaned
|
||||
for range windowsSandboxRuntimeOwnedDepth {
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", nil, false
|
||||
}
|
||||
components = append(components, filepath.Base(current))
|
||||
current = parent
|
||||
}
|
||||
// components came off the tail, deepest first.
|
||||
for index := range windowsSandboxRuntimeOwnedNames {
|
||||
if !windowsRuntimeOwnedNameMatches(index, components[len(components)-1-index]) {
|
||||
return "", nil, false
|
||||
}
|
||||
}
|
||||
ordered := make([]string, 0, len(components))
|
||||
for index := len(components) - 1; index >= 0; index-- {
|
||||
ordered = append(ordered, components[index])
|
||||
}
|
||||
return current, ordered, true
|
||||
}
|
||||
|
||||
// errRuntimeTailNotOwned reports a path the rooted traversal will not handle.
|
||||
// Callers must fail rather than quietly opening it by name.
|
||||
var errRuntimeTailNotOwned = errors.New("path is not a sandbox runtime root")
|
||||
|
||||
// windowsRuntimeOwnedNameMatches reports whether one component of a candidate
|
||||
// tail is a name Zero owns at that position.
|
||||
//
|
||||
// The FIRST position has two accepted spellings. The cache-derived root uses the
|
||||
// fixed name; the temp-derived fallback scopes that component to the user on
|
||||
// platforms where the temp root is shared between accounts, because every
|
||||
// private ownership-checked ancestor has to sit inside one user's namespace.
|
||||
// Both spellings are Zero's own and everything below them is fixed.
|
||||
//
|
||||
// Accepting only the fixed name silently cost the fallback BOTH protections it
|
||||
// depends on: the rooted no-follow traversal fell back to opening the tree by
|
||||
// name, and the shape guard stopped recognising it. Neither failure is visible
|
||||
// at the point it happens, which is why the shape has a test of its own.
|
||||
func windowsRuntimeOwnedNameMatches(index int, component string) bool {
|
||||
if strings.EqualFold(component, windowsSandboxRuntimeOwnedNames[index]) {
|
||||
return true
|
||||
}
|
||||
if index != 0 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(component, fallbackOwnedNamesForMatch()[0])
|
||||
}
|
||||
|
||||
// fallbackOwnedNamesForMatch is the seam the shape tests use. The scoped
|
||||
// spelling only exists on platforms whose temp root is shared, so without it the
|
||||
// accepting branch cannot be exercised at all on Windows, and a break there
|
||||
// would only surface on another platform's CI.
|
||||
var fallbackOwnedNamesForMatch = sandboxRuntimeFallbackOwnedNames
|
||||
@@ -1,11 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "errors"
|
||||
|
||||
var errNoRootedStampWriter = errors.New("no rooted stamp writer on this platform")
|
||||
|
||||
func writeRuntimeStampThroughHandle(root string, planHash string) error {
|
||||
return writeWindowsRuntimeStampThroughHandle(root, planHash)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// errNoRootedStampWriter marks the platforms with no rooted traversal. The
|
||||
// runtime stamp is a Windows concept; the code that writes it is shared only so
|
||||
// its tests run everywhere.
|
||||
var errNoRootedStampWriter = errors.New("no rooted stamp writer on this platform")
|
||||
|
||||
func writeRuntimeStampThroughHandle(string, string) error {
|
||||
return errNoRootedStampWriter
|
||||
}
|
||||
|
||||
// readWindowsSandboxRuntimeStampFile is a plain read off Windows, where no
|
||||
// publish contends with it.
|
||||
func readWindowsSandboxRuntimeStampFile(path string) ([]byte, error) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsFileAddFile is FILE_ADD_FILE, the directory right to create a file in
|
||||
// it. x/sys/windows does not export it.
|
||||
const windowsFileAddFile = 0x00000002
|
||||
|
||||
// A PATHNAME IS NOT AN OBJECT.
|
||||
//
|
||||
// refuseReparsedRuntimeAncestors inspects the owned components before and after
|
||||
// creation, and that is a check-then-use however many times it runs. Everything
|
||||
// afterwards reopens the tree BY NAME: openWindowsACLTarget passes the whole
|
||||
// path to CreateFile, and FILE_FLAG_OPEN_REPARSE_POINT governs only the final
|
||||
// component, so every ancestor is resolved normally. A local user who plants a
|
||||
// junction at an owned ancestor between the last check and the open gets the
|
||||
// capability ACL written to an ordinary leaf inside a directory they chose.
|
||||
// Windows junctions need no privilege to create, so this is not a theoretical
|
||||
// attacker.
|
||||
//
|
||||
// A second Lstat narrows that window; it cannot close it. The only thing that
|
||||
// closes it is never resolving the path again: open the base once, then descend
|
||||
// one component at a time RELATIVE TO THE HANDLE ABOVE IT, refusing a reparse
|
||||
// point at each step, and use the handle that comes out for everything that
|
||||
// follows. NtCreateFile is what allows a relative open at all; Win32 CreateFile
|
||||
// has no equivalent.
|
||||
|
||||
// runtimeTailNotOwned wraps errRuntimeTailNotOwned with the path refused. It
|
||||
// lives here rather than beside the sentinel because this traversal is its only
|
||||
// caller, and in the shared file it was dead code on every other platform.
|
||||
func runtimeTailNotOwned(root string) error {
|
||||
return fmt.Errorf("%w: %s", errRuntimeTailNotOwned, root)
|
||||
}
|
||||
|
||||
// openWindowsRuntimeTailDirectory walks the components Zero owns and returns a
|
||||
// handle to the runtime root itself. The caller closes it.
|
||||
func openWindowsRuntimeTailDirectory(root string, access uint32) (windows.Handle, error) {
|
||||
base, components, ok := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !ok {
|
||||
// NOT a fallback to opening by name. A path that does not have a runtime
|
||||
// root's shape is one this traversal cannot vouch for, and the whole point
|
||||
// is to stop trusting a name.
|
||||
return 0, runtimeTailNotOwned(root)
|
||||
}
|
||||
// The base belongs to the user, so it is opened by name and its own reparse
|
||||
// points are followed: a redirected LOCALAPPDATA is an ordinary machine
|
||||
// configuration, not an attack.
|
||||
parent, err := openWindowsDirectoryByName(base)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
for index, name := range components {
|
||||
// FILE_READ_ATTRIBUTES on every component, including the intermediates:
|
||||
// each open is followed by a GetFileInformationByHandle to decide whether
|
||||
// it is a reparse point, and without that right the check itself fails with
|
||||
// "Access is denied" and refuses the whole tree.
|
||||
wanted := access | windows.FILE_READ_ATTRIBUTES
|
||||
if index < len(components)-1 {
|
||||
// Intermediate components are only traversed.
|
||||
wanted = windows.FILE_TRAVERSE | windows.FILE_READ_ATTRIBUTES | windows.SYNCHRONIZE
|
||||
}
|
||||
child, err := openWindowsChildNoFollow(parent, name, wanted, windows.FILE_DIRECTORY_FILE)
|
||||
_ = windows.CloseHandle(parent)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parent = child
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
func openWindowsDirectoryByName(path string) (windows.Handle, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.FILE_TRAVERSE|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,
|
||||
)
|
||||
}
|
||||
|
||||
// openWindowsChildNoFollow opens exactly one component beneath parent.
|
||||
//
|
||||
// FILE_OPEN_REPARSE_POINT makes the open land on a link rather than following
|
||||
// it, so a swapped component is opened as the link it is and then refused,
|
||||
// instead of silently resolving into somebody else's tree. Since the name is
|
||||
// relative to a handle, no ancestor is re-resolved and there is no interval for
|
||||
// a swap to land in.
|
||||
func openWindowsChildNoFollow(parent windows.Handle, name string, access uint32, options uint32) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode sandbox runtime 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 iosb windows.IO_STATUS_BLOCK
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
access|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
options|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open sandbox runtime component %s: %w", name, err)
|
||||
}
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("inspect sandbox runtime component %s: %w", name, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("refusing to use the sandbox runtime through a link at %s: a reparse point here redirects the directory the sandbox is granted write access to", name)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// writeWindowsRuntimeStampToDirectoryHandle publishes the stamp into an ALREADY
|
||||
// OPEN directory, naming nothing. The caller holds the handle the capability ACE
|
||||
// was applied through, so the stamp cannot land anywhere else. The stamp is
|
||||
// replaced in one step; see publishWindowsRuntimeStamp.
|
||||
func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, name string, planHash string) error {
|
||||
// BEFORE ANYTHING IS CREATED. A reader that could not be established used to
|
||||
// fail after the stamp had already been truncated. Everything that can fail
|
||||
// without touching the stamp still fails before anything is created.
|
||||
// Reported by CodeRabbit.
|
||||
reader, err := windowsRuntimeStampReader(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// PROTECTED FROM THE FIRST INSTANT, because the stamp lives inside the tree
|
||||
// it attests. See windowsRuntimeStampDACL.
|
||||
descriptor, err := windowsRuntimeStampSecurityDescriptor(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return publishWindowsRuntimeStamp(directory, name, []byte(planHash), descriptor)
|
||||
}
|
||||
|
||||
// windowsRuntimeStampReader resolves the identity that has to read the stamp
|
||||
// AFTER setup returns, from the runtime root the stamp is created in.
|
||||
//
|
||||
// Taken from the DIRECTORY HANDLE rather than the setup token, because the
|
||||
// question is not who elevated but whose install this is. Setup runs elevated
|
||||
// and may run as a different administrator account than the one that later runs
|
||||
// the command or zero doctor; the runtime root lives under the ordinary user
|
||||
// profile and its owner is stable across that boundary.
|
||||
// windowsSetupConsumerSID is the ordinary reader carried across the elevation
|
||||
// boundary by `zero sandbox setup`, resolved in the operator's shell.
|
||||
//
|
||||
// Guarded because tests set it; production writes it once, before setup touches
|
||||
// anything, and clears it on the way out.
|
||||
var (
|
||||
windowsSetupConsumerMu sync.Mutex
|
||||
windowsSetupConsumerSID *windows.SID
|
||||
)
|
||||
|
||||
func setWindowsSetupConsumerSID(sid *windows.SID) func() {
|
||||
windowsSetupConsumerMu.Lock()
|
||||
previous := windowsSetupConsumerSID
|
||||
windowsSetupConsumerSID = sid
|
||||
windowsSetupConsumerMu.Unlock()
|
||||
return func() {
|
||||
windowsSetupConsumerMu.Lock()
|
||||
windowsSetupConsumerSID = previous
|
||||
windowsSetupConsumerMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func carriedWindowsSetupConsumerSID() *windows.SID {
|
||||
windowsSetupConsumerMu.Lock()
|
||||
defer windowsSetupConsumerMu.Unlock()
|
||||
return windowsSetupConsumerSID
|
||||
}
|
||||
|
||||
// windowsRuntimeStampReader resolves the identity that must READ the stamp once
|
||||
// setup has returned.
|
||||
//
|
||||
// The carried SID wins. It is the token that will actually run the commands,
|
||||
// resolved before elevation, and it is the only source that survives the token
|
||||
// boundary: the elevated helper CREATES the runtime leaf when it is absent, so
|
||||
// deriving the reader from that leaf yields BUILTINAdministrators. A later
|
||||
// UAC-filtered administrator carries that group deny-only and a standard user
|
||||
// given alternate admin credentials is not in it at all, so the protected stamp
|
||||
// would end up with no enabled allow ACE for the token that has to validate it,
|
||||
// and every restricted command would stop before launch on a setup that had
|
||||
// just reported success.
|
||||
//
|
||||
// The owner fallback stays for the paths that are not setup, notably rollback
|
||||
// recreating a stamp it just removed, where the leaf already exists and belongs
|
||||
// to whoever owns the install.
|
||||
func windowsRuntimeStampReader(directory windows.Handle) (*windows.SID, error) {
|
||||
if carried := carriedWindowsSetupConsumerSID(); carried != nil {
|
||||
return carried, nil
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(directory, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err)
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err)
|
||||
}
|
||||
if owner == nil {
|
||||
return nil, fmt.Errorf("read the sandbox runtime root owner: the descriptor carried none")
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
// windowsRuntimeStampDACL builds the stamp its own DACL, excluding the
|
||||
// capability SID the sandboxed command runs with.
|
||||
//
|
||||
// THE ATTESTATION CANNOT LIVE IN THE SUBJECT'S OWN WRITABLE NAMESPACE. The
|
||||
// runtime root carries an AllowWrite entry for the capability SID with
|
||||
// SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is exactly what lets a sandboxed
|
||||
// command write TMP, GOCACHE and the package-manager caches under it. A file
|
||||
// created inside that root inherits the same grant, so the restricted command
|
||||
// could open the stamp and overwrite it after passing its own pre-launch
|
||||
// validation. Its current command would continue, and every later elevated
|
||||
// command and zero doctor would then reject the altered plan hash until an
|
||||
// Administrator re-ran setup: a sandboxed process bricking the sandbox.
|
||||
//
|
||||
// Moving the stamp outside the tree is the other way to fix it and is worse:
|
||||
// the stamp works precisely because it dies with the tree, so eviction is
|
||||
// detectable without reading an ACE. Keeping it inside with inheritance
|
||||
// switched off preserves that and closes the hole.
|
||||
//
|
||||
// PROTECTED, not merely explicit: without SE_DACL_PROTECTED the inherited
|
||||
// capability ACE stays in the DACL alongside whatever is set here.
|
||||
func windowsRuntimeStampDACL(reader *windows.SID) (*windows.ACL, error) {
|
||||
if reader == nil {
|
||||
return nil, fmt.Errorf("resolve the reader SID for the sandbox runtime stamp: no identity was supplied")
|
||||
}
|
||||
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve LocalSystem SID for the sandbox runtime stamp: %w", err)
|
||||
}
|
||||
administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Administrators SID for the sandbox runtime stamp: %w", err)
|
||||
}
|
||||
// Setup writes it, doctor and the elevated command read it; nothing else
|
||||
// needs to reach it, and the capability SID is deliberately absent.
|
||||
//
|
||||
// The reader is named EXPLICITLY and gets READ ONLY. It used to be
|
||||
// WinCreatorOwnerSid at GENERIC_ALL. SetSecurityInfo does substitute that
|
||||
// placeholder even in a NO_INHERITANCE ACE, so the resulting ACE did name a
|
||||
// concrete SID, but the one it named was whoever happened to run setup. When
|
||||
// setup is elevated by a different administrator account than the one that
|
||||
// later runs the command or zero doctor, the reader matches no ACE and
|
||||
// os.ReadFile on the stamp returns Access is denied, so a successful setup
|
||||
// hands over an unreadable attestation. Resolving the identity from the
|
||||
// runtime root the stamp lives in binds it to the install rather than to the
|
||||
// elevation.
|
||||
//
|
||||
// Read and DELETE, not write. Withholding write from the SANDBOX is the real
|
||||
// boundary and the capability SID has no ACE here at all. Withholding it from
|
||||
// the root owner is not: they own the parent directory, so delete-then-create
|
||||
// forges a stamp exactly as well as an overwrite would. What read-only would
|
||||
// actually cost is rollback, which has to remove a stamp this run wrote under
|
||||
// the same token that wrote it.
|
||||
//
|
||||
// So: no FILE_WRITE_DATA, no WRITE_DAC, no WRITE_OWNER, which keeps an
|
||||
// accidental in-place rewrite off the table, and DELETE so compensation can
|
||||
// undo its own work and the next publish can rename a replacement over it.
|
||||
// The publish itself still writes: see createWindowsStampReplacement.
|
||||
// The reader can BE one of the repair identities. A runtime root created by
|
||||
// an elevated process is commonly owned by BUILTINAdministrators rather than
|
||||
// by the invoking user, which is what CI runners do. Naming the same SID
|
||||
// twice let the narrower read-only entry win and left repair unable to rewrite
|
||||
// the stamp, so skip the reader entry when the broader grant already covers it.
|
||||
entries := make([]windows.EXPLICIT_ACCESS, 0, 3)
|
||||
if !reader.Equals(system) && !reader.Equals(administrators) {
|
||||
entries = append(entries, windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: windows.FILE_GENERIC_READ | windows.DELETE,
|
||||
AccessMode: windows.SET_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(reader),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, sid := range []*windows.SID{system, administrators} {
|
||||
entries = append(entries, windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.SET_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_GROUP,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||
},
|
||||
})
|
||||
}
|
||||
dacl, err := windows.ACLFromEntries(entries, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build the sandbox runtime stamp DACL: %w", err)
|
||||
}
|
||||
return dacl, nil
|
||||
}
|
||||
|
||||
// windowsRuntimeStampSecurityDescriptor is the descriptor a stamp is created
|
||||
// with: windowsRuntimeStampDACL, protected so nothing is inherited from the root.
|
||||
func windowsRuntimeStampSecurityDescriptor(reader *windows.SID) (*windows.SECURITY_DESCRIPTOR, error) {
|
||||
dacl, err := windowsRuntimeStampDACL(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
descriptor, err := windows.NewSecurityDescriptor()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build the sandbox runtime stamp security descriptor: %w", err)
|
||||
}
|
||||
if err := descriptor.SetDACL(dacl, true, false); err != nil {
|
||||
return nil, fmt.Errorf("build the sandbox runtime stamp security descriptor: %w", err)
|
||||
}
|
||||
if err := descriptor.SetControl(windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED); err != nil {
|
||||
return nil, fmt.Errorf("protect the sandbox runtime stamp security descriptor: %w", err)
|
||||
}
|
||||
return descriptor, nil
|
||||
}
|
||||
|
||||
// writeWindowsRuntimeStampThroughHandle writes the setup stamp INTO the object
|
||||
// the traversal reached, not into whatever the pathname resolves to now.
|
||||
//
|
||||
// The old writer used MkdirAll and a pathname write, which left a second
|
||||
// unbound interval: a tree replaced after the ACL apply could be recreated and
|
||||
// stamped without ever carrying the capability grant, and marker validation
|
||||
// still passed because it only reads the stamp's contents. The restricted
|
||||
// process then got a marker-valid runtime path with no grant on it.
|
||||
//
|
||||
// ONE WRITER. This path used to carry its own copy of the create, protect and
|
||||
// write sequence, so an ordering fix to one copy left the other behind. It
|
||||
// delegates now, and the stamp is protected and ordered the same way whichever
|
||||
// handle it arrives through.
|
||||
func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error {
|
||||
directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(directory)
|
||||
return writeWindowsRuntimeStampToDirectoryHandle(directory, windowsSandboxRuntimeStampName(planHash), planHash)
|
||||
}
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user