esengine--deepseek-reasonix
579 行
22 KiB
Go
579 行
22 KiB
Go
//go:build windows
|
|
|
|
package winsandbox
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
// Reasonix launches every sandboxed command as its own helper process, so two
|
|
// commands running against the same workspace are separate OS processes. This
|
|
// sandbox enforces its boundary by temporarily mutating a path's ACLs and
|
|
// integrity label and restoring them from a snapshot afterward. If two
|
|
// processes did that on the same root concurrently, their grant/deny edits and
|
|
// snapshot restores would interleave: one command's cleanup would tear down the
|
|
// boundary the other still relies on, producing either a false permission
|
|
// failure or — worse — a lapse in the forbid_read / writable boundary. A shared
|
|
// path has no per-process ACL view, so short of re-architecting the isolation
|
|
// primitive the only safe option is to serialize whole runs that touch the same
|
|
// root.
|
|
//
|
|
// windowsRootLock takes a per-root named mutex for the lifetime of a run. The
|
|
// mutex lives in the session-local namespace and the OS releases it
|
|
// automatically if the holder dies, so a crashed command never deadlocks the
|
|
// next one (WAIT_ABANDONED is treated as ownership). Multiple roots are locked
|
|
// in a stable sorted order so two processes cannot deadlock by acquiring them
|
|
// in opposite orders.
|
|
//
|
|
// Trade-off: a long-running sandboxed command (including a background job)
|
|
// holds its root's lock for its whole lifetime, so other sandboxed commands on
|
|
// the same root queue behind it. That is the price of a mutation-based sandbox;
|
|
// the alternative is boundary corruption. The wait is bounded (a short
|
|
// interactive default, a longer Spec.LockWait budget for background jobs,
|
|
// WINDOWS_SANDBOX_LOCK_MS overriding both — see lockinfo.go) so a stuck holder
|
|
// surfaces as a clear error naming the holding command instead of an
|
|
// indefinite hang.
|
|
|
|
// stillActiveExitCode is STILL_ACTIVE: GetExitCodeProcess reports it while a
|
|
// process is running. Used to tell a live marker-owner from a dead one.
|
|
const stillActiveExitCode = 259
|
|
|
|
// Windows mutexes have thread affinity: the thread that acquires a mutex is the
|
|
// only one that can release it (ReleaseMutex from another thread fails with
|
|
// ERROR_NOT_OWNER), and the owning thread re-acquiring is a no-op that would
|
|
// break serialization within a process. A Go goroutine can migrate across OS
|
|
// threads between the acquire and the deferred release, so the lock pins itself
|
|
// to one OS thread for its whole lifetime with runtime.LockOSThread and unpins
|
|
// on release. This keeps ReleaseMutex on the owning thread and prevents a
|
|
// concurrent goroutine that happens to land on the owner thread from re-entering
|
|
// the mutex.
|
|
type windowsRootLock struct {
|
|
handles []windows.Handle
|
|
names []string
|
|
pinned bool
|
|
}
|
|
|
|
// lockWindowsRoots serializes access to every distinct root in roots. The
|
|
// returned lock must be released once the run's grants have been cleaned up.
|
|
// notice, when non-nil, receives a one-line message if acquisition has to
|
|
// queue behind another run for more than windowsRootLockNoticeAfter.
|
|
// holderLabel is this run's command preview, recorded so a queued command can
|
|
// name what is blocking it; lockWait is the caller's wait budget (see
|
|
// windowsRootLockTimeout).
|
|
func lockWindowsRoots(roots []string, notice io.Writer, holderLabel string, lockWait time.Duration) (*windowsRootLock, error) {
|
|
names := windowsRootLockNames(roots)
|
|
if len(names) == 0 {
|
|
return &windowsRootLock{}, nil
|
|
}
|
|
lock := &windowsRootLock{}
|
|
// Pin before acquiring the first mutex and stay pinned until release so every
|
|
// mutex is owned by, and released from, the same OS thread.
|
|
runtime.LockOSThread()
|
|
lock.pinned = true
|
|
timeout := windowsRootLockTimeout(lockWait)
|
|
for _, name := range names {
|
|
h, err := acquireNamedMutex(name, timeout, notice)
|
|
if err != nil {
|
|
lock.release()
|
|
return nil, err
|
|
}
|
|
lock.handles = append(lock.handles, h)
|
|
lock.names = append(lock.names, name)
|
|
writeWindowsLockHolder(name, holderLabel)
|
|
}
|
|
return lock, nil
|
|
}
|
|
|
|
func (l *windowsRootLock) release() {
|
|
if l == nil {
|
|
return
|
|
}
|
|
for i := len(l.handles) - 1; i >= 0; i-- {
|
|
h := l.handles[i]
|
|
if h == 0 {
|
|
continue
|
|
}
|
|
// Drop the holder record before releasing the mutex so the next owner
|
|
// never races its own record against ours.
|
|
removeWindowsLockHolder(l.names[i])
|
|
_ = windows.ReleaseMutex(h)
|
|
_ = windows.CloseHandle(h)
|
|
}
|
|
l.handles = nil
|
|
l.names = nil
|
|
if l.pinned {
|
|
runtime.UnlockOSThread()
|
|
l.pinned = false
|
|
}
|
|
}
|
|
|
|
// windowsRootLockNames maps roots to a deduplicated, sorted list of mutex
|
|
// names. Sorting guarantees a global acquisition order across processes so a
|
|
// multi-root run cannot deadlock against another acquiring the same roots in a
|
|
// different order.
|
|
func windowsRootLockNames(roots []string) []string {
|
|
seen := map[string]bool{}
|
|
var names []string
|
|
for _, root := range roots {
|
|
key := strings.ToLower(filepath.Clean(root))
|
|
if key == "" || key == "." || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
sum := sha1.Sum([]byte(key))
|
|
names = append(names, `Local\windows-sandbox.`+hex.EncodeToString(sum[:16]))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// windowsRootLockNoticeAfter is how long a lock acquisition may block silently
|
|
// before a one-line notice is emitted. Queueing behind another sandboxed
|
|
// command's whole-run lock is by design (see the package comment above), but
|
|
// before this notice existed a queued command was indistinguishable from a
|
|
// hung one — users read the silence as "bash is broken" (#6067).
|
|
const windowsRootLockNoticeAfter = 2 * time.Second
|
|
|
|
func acquireNamedMutex(name string, timeout time.Duration, notice io.Writer) (windows.Handle, error) {
|
|
name16, err := windows.UTF16PtrFromString(name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// CreateMutex returns a valid handle even when the named mutex already
|
|
// exists (err == ERROR_ALREADY_EXISTS); only a zero handle is a real error.
|
|
h, err := windows.CreateMutex(nil, false, name16)
|
|
if h == 0 {
|
|
return 0, fmt.Errorf("create sandbox mutex %q: %w", name, err)
|
|
}
|
|
// Wait in slices so a long queue surfaces a notice instead of blocking
|
|
// silently until the deadline.
|
|
var waited time.Duration
|
|
noticed := false
|
|
for {
|
|
slice := windowsRootLockNoticeAfter
|
|
if timeout > 0 {
|
|
if remaining := timeout - waited; remaining < slice {
|
|
slice = remaining
|
|
}
|
|
}
|
|
event, werr := windows.WaitForSingleObject(h, uint32(slice/time.Millisecond))
|
|
switch event {
|
|
case windows.WAIT_OBJECT_0, windows.WAIT_ABANDONED:
|
|
// WAIT_ABANDONED means the previous holder died without releasing. We now
|
|
// own the mutex; any filesystem residue that run left behind is cleared by
|
|
// sweepWindowsDenyResidue before we re-apply, and the integrity-label reset
|
|
// on cleanup returns the tree to Medium.
|
|
return h, nil
|
|
case uint32(windows.WAIT_TIMEOUT):
|
|
waited += slice
|
|
if timeout > 0 && waited >= timeout {
|
|
_ = windows.CloseHandle(h)
|
|
if holder := describeWindowsLockHolder(name); holder != "" {
|
|
return 0, fmt.Errorf("timed out waiting for sandbox lock %q after %s (%s; stop that command or set WINDOWS_SANDBOX_LOCK_MS higher)", name, timeout, holder)
|
|
}
|
|
return 0, fmt.Errorf("timed out waiting for sandbox lock %q after %s (stop the earlier sandboxed command or set WINDOWS_SANDBOX_LOCK_MS higher)", name, timeout)
|
|
}
|
|
if !noticed && notice != nil {
|
|
noticed = true
|
|
limit := "no limit"
|
|
if timeout > 0 {
|
|
limit = timeout.String()
|
|
}
|
|
if holder := describeWindowsLockHolder(name); holder != "" {
|
|
fmt.Fprintf(notice, "windows sandbox: waiting for another sandboxed command on this workspace to finish (%s; lock wait limit %s; stop that command or set WINDOWS_SANDBOX_LOCK_MS to adjust)\n", holder, limit)
|
|
} else {
|
|
fmt.Fprintf(notice, "windows sandbox: waiting for another sandboxed command on this workspace to finish (lock wait limit %s; stop the earlier background/long-running command or set WINDOWS_SANDBOX_LOCK_MS to adjust)\n", limit)
|
|
}
|
|
}
|
|
default:
|
|
_ = windows.CloseHandle(h)
|
|
if werr != nil {
|
|
return 0, fmt.Errorf("wait for sandbox lock %q: %w", name, werr)
|
|
}
|
|
return 0, fmt.Errorf("wait for sandbox lock %q returned %#x", name, event)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lock-holder records live under the same user-shared %TEMP% as the residue
|
|
// markers, one file per root digest. All operations are best-effort: the record
|
|
// is diagnostic, so a write or read failure must never affect the run.
|
|
func windowsLockHolderDir() string {
|
|
return filepath.Join(os.TempDir(), "windows-sandbox-lockholders")
|
|
}
|
|
|
|
func windowsLockHolderPath(mutexName string) string {
|
|
return filepath.Join(windowsLockHolderDir(), lockHolderFileName(mutexName))
|
|
}
|
|
|
|
func writeWindowsLockHolder(mutexName, label string) {
|
|
if err := os.MkdirAll(windowsLockHolderDir(), 0o700); err != nil {
|
|
return
|
|
}
|
|
rec := lockHolderRecord{pid: os.Getpid(), startUnixMS: time.Now().UnixMilli(), label: label}
|
|
_ = os.WriteFile(windowsLockHolderPath(mutexName), []byte(formatLockHolderRecord(rec)), 0o600)
|
|
}
|
|
|
|
func removeWindowsLockHolder(mutexName string) {
|
|
_ = os.Remove(windowsLockHolderPath(mutexName))
|
|
}
|
|
|
|
// describeWindowsLockHolder names the run currently holding mutexName, or ""
|
|
// when no trustworthy record exists. A record whose PID is dead is a crash
|
|
// leftover (the OS already released the abandoned mutex); it is skipped, not
|
|
// deleted, because removal here could race the file the next live holder just
|
|
// wrote — the next acquire overwrites it anyway.
|
|
func describeWindowsLockHolder(mutexName string) string {
|
|
data, err := os.ReadFile(windowsLockHolderPath(mutexName))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
rec, ok := parseLockHolderRecord(string(data))
|
|
if !ok || !windowsProcessAlive(strconv.Itoa(rec.pid)) {
|
|
return ""
|
|
}
|
|
return describeLockHolder(rec, time.Now())
|
|
}
|
|
|
|
// windowsMutatedRoots is the set of paths a run edits ACLs on: its writable
|
|
// roots plus any forbid_read roots that exist. These are the paths that must be
|
|
// serialized against concurrent runs and tracked for crash-residue cleanup.
|
|
func windowsMutatedRoots(spec Spec) []string {
|
|
roots := append([]string(nil), windowsWritableRoots(spec)...)
|
|
for _, r := range normalizedWindowsRoots(spec.ForbidReadRoots) {
|
|
if pathExists(r) {
|
|
roots = append(roots, r)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// windowsMutatedRootsForRun extends windowsMutatedRoots with the executable
|
|
// directories the run will actually mutate ACLs on: the non-system ones. A run
|
|
// snapshots/grants/restores argv[0]'s directory (and a Git install root), so two
|
|
// runs in different workspaces that share one tool directory would otherwise
|
|
// interleave their ACL snapshots and corrupt each other. System tool directories
|
|
// (System32, Program Files) are never mutated (grantAppContainerExecutable skips
|
|
// them), so they must stay out of the lock too — both sets draw from the same
|
|
// windowsMutableExecutableGrantRoots so the locked paths and the mutated paths
|
|
// cannot drift apart, and every command sharing the system shell is spared a
|
|
// needless serialization.
|
|
func windowsMutatedRootsForRun(spec Spec, exe string) []string {
|
|
roots := windowsMutatedRoots(spec)
|
|
return append(roots, windowsMutableExecutableGrantRoots(exe)...)
|
|
}
|
|
|
|
// isWindowsSystemRoot reports whether path is inside a Windows system location
|
|
// (%SystemRoot%, the Program Files variants). Determined by path membership, not
|
|
// by attempting a write, so the result is stable regardless of the process's
|
|
// integrity level or admin rights. Backs windowsMutableExecutableGrantRoots,
|
|
// which keeps shared system directories out of both the per-root lock set and
|
|
// the executable grant/residue set.
|
|
func isWindowsSystemRoot(path string) bool {
|
|
clean := strings.ToLower(filepath.Clean(path))
|
|
for _, envVar := range []string{"SystemRoot", "windir", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"} {
|
|
root := os.Getenv(envVar)
|
|
if root == "" {
|
|
continue
|
|
}
|
|
root = strings.ToLower(filepath.Clean(root))
|
|
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// forbid_read applies a deny ACE for the current user SID so a same-user
|
|
// low-integrity child cannot bypass the deny through normal file ACLs, and
|
|
// writable runs grant AppContainer/user read+execute on the tool executable's
|
|
// directory. Both are removed on normal cleanup, but a crash (or force-kill)
|
|
// skips cleanup and leaves residue: a deny ACE locks the user out of, e.g.,
|
|
// ~/.ssh until they repair the ACL by hand; a stale grant silently widens read
|
|
// access to a tool directory. The residue tracker records each mutated path in a
|
|
// per-PID marker *before* the ACE is applied, so any crash point leaves a marker
|
|
// the next run can sweep; the next run removes the residue for markers whose
|
|
// owning process is gone. Only the stable, sandbox-applied trustees are removed,
|
|
// so legitimate ACLs are left untouched.
|
|
//
|
|
// Each marker line is "<kind> <path>", where kind is "deny" or "grant". Lines
|
|
// are appended and fsync'd one at a time, and a write failure aborts the run
|
|
// before the corresponding ACE is applied, so the marker can never lag behind
|
|
// the on-disk ACLs.
|
|
|
|
type residueKind string
|
|
|
|
const (
|
|
residueDeny residueKind = "deny"
|
|
residueGrant residueKind = "grant"
|
|
)
|
|
|
|
type residueEntry struct {
|
|
kind residueKind
|
|
path string
|
|
}
|
|
|
|
func windowsDenyMarkerDir() string {
|
|
return filepath.Join(os.TempDir(), "windows-sandbox-denylocks")
|
|
}
|
|
|
|
func windowsDenyMarkerPath() string {
|
|
return filepath.Join(windowsDenyMarkerDir(), strconv.Itoa(os.Getpid())+".txt")
|
|
}
|
|
|
|
func windowsNewDenyMarkerPath() string {
|
|
pid := strconv.Itoa(os.Getpid())
|
|
for i := 0; i < 8; i++ {
|
|
if nonce, ok := windowsResidueNonce(); ok {
|
|
marker := filepath.Join(windowsDenyMarkerDir(), pid+"-"+nonce+".txt")
|
|
if !pathExists(marker) {
|
|
return marker
|
|
}
|
|
}
|
|
}
|
|
seq := residueMarkerSeq.Add(1)
|
|
return filepath.Join(windowsDenyMarkerDir(), fmt.Sprintf("%s-%d-%d.txt", pid, time.Now().UnixNano(), seq))
|
|
}
|
|
|
|
func windowsResidueNonce() (string, bool) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", false
|
|
}
|
|
return hex.EncodeToString(b[:]), true
|
|
}
|
|
|
|
func windowsDenyMarkerOwnerPID(name string) (string, bool) {
|
|
if !strings.HasSuffix(name, ".txt") {
|
|
return "", false
|
|
}
|
|
stem := strings.TrimSuffix(name, ".txt")
|
|
pid, _, _ := strings.Cut(stem, "-")
|
|
if pid == "" {
|
|
return "", false
|
|
}
|
|
if _, err := strconv.ParseUint(pid, 10, 32); err != nil {
|
|
return "", false
|
|
}
|
|
return pid, true
|
|
}
|
|
|
|
var (
|
|
residueMarkerSeq atomic.Uint64
|
|
liveResidueMarkers sync.Map
|
|
)
|
|
|
|
// windowsResidueRun owns one run-local crash-residue marker. Marker filenames
|
|
// include the process PID so a later process can tell whether the owner is gone,
|
|
// plus a nonce so multiple concurrent Run calls in the same process never share
|
|
// a marker. The live registry protects this process's own active markers from a
|
|
// concurrent sweep; an unregistered marker with our PID is therefore a crashed
|
|
// predecessor's residue after Windows reused the PID.
|
|
type windowsResidueRun struct {
|
|
marker string
|
|
owned atomic.Bool
|
|
}
|
|
|
|
func newWindowsDenyResidueRun() *windowsResidueRun {
|
|
r := &windowsResidueRun{marker: windowsNewDenyMarkerPath()}
|
|
liveResidueMarkers.Store(r.marker, struct{}{})
|
|
return r
|
|
}
|
|
|
|
// recordBeforeApply appends one "<kind>\t<path>" line to this run's marker and
|
|
// flushes it to disk. It is called immediately before the matching ACE is
|
|
// applied; a failure here must abort the run before the ACE is applied so the
|
|
// marker never lags the on-disk ACLs. Returns an error so the caller can fail
|
|
// closed. A tab separates the fields because Windows paths never contain one, so
|
|
// the path is recovered unambiguously regardless of spaces in it.
|
|
func (r *windowsResidueRun) recordBeforeApply(kind residueKind, path string) error {
|
|
if r == nil {
|
|
return fmt.Errorf("residue marker is required")
|
|
}
|
|
dir := windowsDenyMarkerDir()
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create residue marker dir: %w", err)
|
|
}
|
|
f, err := os.OpenFile(r.marker, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return fmt.Errorf("open residue marker: %w", err)
|
|
}
|
|
defer f.Close()
|
|
r.owned.Store(true)
|
|
if _, err := f.WriteString(string(kind) + "\t" + path + "\n"); err != nil {
|
|
return fmt.Errorf("write residue marker: %w", err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return fmt.Errorf("sync residue marker: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// clear drops this run's marker after its own cleanup has removed every
|
|
// recorded ACE. It only removes the run-local marker this object created; stale
|
|
// predecessor markers that happen to carry this process's PID stay available for
|
|
// the next sweep instead of being orphaned.
|
|
func (r *windowsResidueRun) clear() {
|
|
if r == nil {
|
|
return
|
|
}
|
|
liveResidueMarkers.Delete(r.marker)
|
|
if !r.owned.Load() {
|
|
return
|
|
}
|
|
_ = os.Remove(r.marker)
|
|
r.owned.Store(false)
|
|
}
|
|
|
|
// sweepWindowsDenyResidue removes ACEs left by crashed runs. It only acts on
|
|
// markers whose owning process is provably gone: a PID that is no longer
|
|
// alive, or a file with this process's PID that is not in this process's live
|
|
// marker registry. Windows reuses PIDs, so an unregistered self-PID marker
|
|
// records a dead predecessor's residue, while registered self-PID markers belong
|
|
// to concurrent live Run calls. Only the stable sandbox-applied trustees are
|
|
// removed, so it never disturbs a live run or a legitimate ACL. Best-effort: any
|
|
// error is ignored so it can never block a run.
|
|
func sweepWindowsDenyResidue() {
|
|
dir := windowsDenyMarkerDir()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
sandboxSIDs := sandboxResidueSIDs()
|
|
self := strconv.Itoa(os.Getpid())
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
pid, ok := windowsDenyMarkerOwnerPID(entry.Name())
|
|
if !ok {
|
|
continue
|
|
}
|
|
markerPath := filepath.Join(dir, entry.Name())
|
|
if pid == self {
|
|
// Our PID is live only for markers registered by this process. Any
|
|
// unregistered self-PID marker was left by a dead predecessor after PID
|
|
// reuse, so consume it like ordinary crash residue.
|
|
if _, live := liveResidueMarkers.Load(markerPath); live {
|
|
continue
|
|
}
|
|
} else if windowsProcessAlive(pid) {
|
|
continue
|
|
}
|
|
sweepResidueMarkerFile(markerPath, sandboxSIDs)
|
|
}
|
|
}
|
|
|
|
// sandboxResidueSIDs is the trustee set the sweep removes. The sandbox only
|
|
// ever applies these trustees, for both grants and denies, so removing exactly
|
|
// them cannot disturb a legitimate ACL.
|
|
func sandboxResidueSIDs() []string {
|
|
userSID, _ := currentProcessUserSIDString()
|
|
return dedupeSIDStrings([]string{
|
|
allApplicationPackagesSID,
|
|
allRestrictedApplicationPackagesSID,
|
|
userSID,
|
|
})
|
|
}
|
|
|
|
// sweepResidueMarkerFile removes the ACEs recorded in one dead run's marker,
|
|
// then the marker itself.
|
|
func sweepResidueMarkerFile(markerPath string, sandboxSIDs []string) {
|
|
for _, e := range readResidueMarker(markerPath) {
|
|
if !pathExists(e.path) || !sweepableResidue(e) {
|
|
continue
|
|
}
|
|
switch e.kind {
|
|
case residueDeny:
|
|
removeDeniedAppContainerSIDs(e.path, sandboxSIDs)
|
|
case residueGrant:
|
|
removeGrantedAppContainerSIDs(e.path, sandboxSIDs)
|
|
}
|
|
}
|
|
_ = os.Remove(markerPath)
|
|
}
|
|
|
|
// sweepableResidue reports whether a marker entry names a path the sandbox
|
|
// could actually have mutated. The current code never records a system
|
|
// directory (windowsMutableExecutableGrantRoots filters them before any
|
|
// snapshot, grant, or marker line), but the marker file itself is untrusted
|
|
// input: an older sandbox binary may have written one before that filter
|
|
// existed, and the marker directory lives under the user's %TEMP%, writable to
|
|
// anything running as the same user. Removing the broad built-in package SIDs
|
|
// (ALL APPLICATION PACKAGES / ALL RESTRICTED APPLICATION PACKAGES) from
|
|
// System32 or Program Files would strip factory ACEs the OS itself relies on,
|
|
// so the sweep re-checks the invariant instead of trusting the marker.
|
|
func sweepableResidue(e residueEntry) bool {
|
|
return !isWindowsSystemRoot(e.path)
|
|
}
|
|
|
|
// readResidueMarker parses "<kind>\t<path>" lines. A tab splits the fields so a
|
|
// path containing spaces is preserved intact. An unrecognized line is skipped
|
|
// rather than guessed at, so a corrupt marker cannot cause a wrong ACE removal.
|
|
func readResidueMarker(path string) []residueEntry {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
var out []residueEntry
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimRight(scanner.Text(), "\r\n")
|
|
if line == "" {
|
|
continue
|
|
}
|
|
kindStr, p, ok := strings.Cut(line, "\t")
|
|
if !ok || p == "" {
|
|
continue
|
|
}
|
|
switch residueKind(kindStr) {
|
|
case residueDeny:
|
|
out = append(out, residueEntry{kind: residueDeny, path: p})
|
|
case residueGrant:
|
|
out = append(out, residueEntry{kind: residueGrant, path: p})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// windowsProcessAlive reports whether the given PID is still running. A parse
|
|
// failure or a process that cannot be opened is treated as not-alive so stale
|
|
// markers get cleaned; PID reuse can only delay cleanup, never corrupt a live
|
|
// run or lose residue: same-process live markers are protected by
|
|
// liveResidueMarkers, and a stale marker whose PID is reused by an unrelated
|
|
// process merely waits until that process exits — unless the reuser is this
|
|
// process, in which case the marker is unregistered and swept at run start.
|
|
func windowsProcessAlive(pidStr string) bool {
|
|
pid, err := strconv.ParseUint(pidStr, 10, 32)
|
|
if err != nil || pid == 0 {
|
|
return false
|
|
}
|
|
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer windows.CloseHandle(handle)
|
|
var code uint32
|
|
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
|
return false
|
|
}
|
|
return code == stillActiveExitCode
|
|
}
|