Documentation
¶
Overview ¶
Package rootwrite catches writes whose containment under a root is resolved lexically only: os.WriteFile / os.Create / os.OpenFile(write flag) / os.MkdirAll / os.Remove on a path built under a root — filepath.Join, or a flat `root + "/" + x` concatenation — where the root is a caller-supplied parameter or field — with no filepath.EvalSymlinks on that path's chain — plus the archive twin: zip.Writer entry names assembled from a parameter with no path.Clean on the entry-name chain.
The bug class: lexical prefix checks cannot see symlinks. Probes TestApplyRefusesSymlinkEscape (framework/contracts report.go containedPath/Apply, fixed in 77fdbaf4: a diagnostic whose path crossed a symlinked directory was written outside the project root even though Join+HasPrefix said "contained") and TestPackZipPrefixCannotEscapeDir (framework/sdk zip.go PackZip, fixed in 1501a555: a "../" prefix placed archive entries above the target directory on extract).
The 2026-09-04 red-probe round proved two blind spots in battery/storage local.go (probe TestLocalStorageSymlinkEscapeRefused) and both are now in the shape:
- the Join lives in a same-package helper that RETURNS the path (fullPath: Join(ls.BaseDir, key)) and the caller acts on the result. A path bound to such a helper's result — the helper joins a root/base/dir-named parameter or receiver field, and its body resolves no symlinks — counts as root-derived at the caller;
- the write is not a plain create: os.Rename / os.Link / os.Symlink DESTINATION arguments (the rename-into-place a storage Save does), os.Remove (a Delete unlinks through a symlinked directory just as Save writes through one), and os.MkdirAll(filepath.Dir(<root-derived path>)) — creating the parent chain of an escaped path creates it outside the root.
The same round's reviewer mutation removed both EvalSymlinks calls from core/upload Save and this rule stayed SILENT on every one of its sinks — the joined component had passed through sanitizeKey, whose RESULT replaced it, and a result-replacing sanitizer used to shield. That shield is gone (the 2026-09-04 posture change): a sanitizer strips "..", it cannot see a symlinked directory, which is exactly how the probes escaped on the read side — rootread never carried the shield. Only RESOLUTION postures gate now, and os.Root is the strongest of them.
Every gate is per write and on the write's own dataflow: resolution or cleaning on an unrelated path (or consulted for a boolean and leaving the path components untouched) gates nothing.
Silent postures, deliberately — os.Root first, as the fix posture of first resort on Go 1.27:
- writes made through an *os.Root method (os.OpenRoot(root) + root.Create / OpenFile / WriteFile / Mkdir / MkdirAll / Remove / RemoveAll / Rename / Link / Symlink): containment is enforced by the kernel — a symlink under the root cannot lead the write out, and there is no TOCTOU window between check and use. Root methods are no os.* sink, so the rule is quiet by construction (the stashViaRoot fixture keeps it that way);
- the write's path (or a component of it, or the Join's root) is bound to a filepath.EvalSymlinks result, or an EvalSymlinks ran on this path expression or on its Dir — resolution on the chain (the fix posture; core/upload Save resolves the storage root and the destination's parent directory before creating anything);
- calls to symlink-named guards (EnsureNoSymlinkPath): resolution by another name;
- O_NOFOLLOW in a write-open's flags, and an Lstat of the sink's own target consulted with ModeSymlink: the leaf postures — documented partial fixes; the directory components above the leaf remain the writer's problem;
- a sanitizer or validator in ANY spelling — result-replacing or boolean: neither can see a symlinked directory, so neither gates. Until the 2026-09-04 posture change a result-replacing one kept the write quiet (fixture d's sanitizerResultStillFires and cleanHelperStillFires are positives now, exactly the silence the mutation proof broke);
- roots that are not parameters or root/base/dir-named fields (a constant or computed root has no caller-controlled boundary to defend), including a rooty local resolved through one;
- builds whose every non-root argument is a literal — nothing caller-controlled is appended under the root. This holds at the helper hop too: a same-package containment helper called with literal-only non-root arguments stays quiet, and a helper whose body resolves symlinks is the fix posture;
- temp roots: a local bound to os.MkdirTemp or t.TempDir is throwaway by construction;
- zip entry names assembled only from literals or non-parameter values (a wrapper forwarding its own name parameter composes nothing), and entry names whose assembly is bound to a path.Clean / filepath.Clean result;
- reads (os.Open, os.ReadFile, O_RDONLY) by construction — rootread owns that side;
- _test.go files.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var Analyzer = &analysis.Analyzer{
Name: "rootwrite",
Doc: "forbids writes under a root whose containment is lexical only: prefer os.OpenRoot (kernel-enforced containment), or resolve with filepath.EvalSymlinks; and path.Clean zip entry names",
Run: run,
}
Functions ¶
This section is empty.
Types ¶
This section is empty.