Add strict pinned cgroup containment for maintenance commands
5 files changed,
+972
−0
internal/cgroup/strict.go+10 −0internal/cgroup/strict_kernel_linux_test.go+343 −0internal/cgroup/strict_linux.go+356 −0internal/cgroup/strict_linux_test.go+246 −0internal/cgroup/strict_other.go+17 −0
diff --git a/internal/cgroup/strict.go b/internal/cgroup/strict.go| new file mode 100644 |
| index 000000000..f8e071ece |
| --- /dev/null |
| +++ b/internal/cgroup/strict.go |
| @@ -0,0 +1,10 @@ |
| +package cgroup |
| + |
| +import "errors" |
| + |
| +var ( |
| + ErrStrictUnsupported = errors.New("cgroup: strict containment unsupported") |
| + ErrStrictRefused = errors.New("cgroup: strict containment refused") |
| + ErrStrictState = errors.New("cgroup: strict containment state refused") |
| + ErrStrictDeadline = errors.New("cgroup: strict containment requires a live deadline") |
| +) |
diff --git a/internal/cgroup/strict_kernel_linux_test.go b/internal/cgroup/strict_kernel_linux_test.go| new file mode 100644 |
| index 000000000..922e09764 |
| --- /dev/null |
| +++ b/internal/cgroup/strict_kernel_linux_test.go |
| @@ -0,0 +1,343 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type strictKernelReceipt struct { |
| + PID int `json:"pid"` |
| + Group string `json:"group"` |
| + Child int `json:"child,omitempty"` |
| +} |
| + |
| +func strictKernelGroup(t *testing.T) string { |
| + t.Helper() |
| + raw, e := os.ReadFile("/proc/self/cgroup") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + group, ok := ParseSelfCgroup(string(raw)) |
| + if !ok { |
| + t.Fatal("no unified group") |
| + } |
| + return "/" + group |
| +} |
| +func strictKernelSave(t *testing.T, name string, child int) { |
| + t.Helper() |
| + raw, e := json.Marshal(strictKernelReceipt{os.Getpid(), strictKernelGroup(t), child}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = os.WriteFile(filepath.Join(os.Getenv("CARLOS_STRICT_PROOF_DIR"), name+".json"), raw, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func strictKernelCommand(mode string) *exec.Cmd { |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestStrictKernelChild$", "-test.count=1") |
| + cmd.Env = []string{"CARLOS_STRICT_CHILD=" + mode, "CARLOS_STRICT_PROOF_DIR=" + os.Getenv("CARLOS_STRICT_PROOF_DIR")} |
| + return cmd |
| +} |
| +func TestStrictKernelChild(t *testing.T) { |
| + mode := os.Getenv("CARLOS_STRICT_CHILD") |
| + if mode == "" { |
| + t.Skip("synthetic child only") |
| + } |
| + if mode == "grandchild" { |
| + strictKernelSave(t, "grandchild", 0) |
| + time.Sleep(time.Minute) |
| + return |
| + } |
| + if mode == "marker" { |
| + strictKernelSave(t, "marker", 0) |
| + return |
| + } |
| + child := strictKernelCommand("grandchild") |
| + child.Stdout = os.Stdout |
| + child.Stderr = os.Stderr |
| + if e := child.Start(); e != nil { |
| + t.Fatal(e) |
| + } |
| + strictKernelSave(t, "worker", child.Process.Pid) |
| + if mode == "exit-with-descendant" { |
| + return |
| + } |
| + time.Sleep(time.Minute) |
| +} |
| +func strictKernelAwait(t *testing.T, name string) strictKernelReceipt { |
| + t.Helper() |
| + path := filepath.Join(os.Getenv("CARLOS_STRICT_PROOF_DIR"), name+".json") |
| + deadline := time.Now().Add(3 * time.Second) |
| + for time.Now().Before(deadline) { |
| + raw, e := os.ReadFile(path) |
| + if e == nil { |
| + var r strictKernelReceipt |
| + if json.Unmarshal(raw, &r) == nil { |
| + return r |
| + } |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + t.Fatal("kernel child receipt missing", name) |
| + return strictKernelReceipt{} |
| +} |
| + |
| +// The local user unit delegates memory but not io. This scaffold moves the |
| +// probe into a supervisor leaf and enables only the controller strict mode uses. |
| +func strictKernelManager(t *testing.T) *Manager { |
| + t.Helper() |
| + root := filepath.Join("/sys/fs/cgroup", strictKernelGroup(t)) |
| + leaf := filepath.Join(root, "supervise") |
| + if e := os.Mkdir(leaf, 0755); e != nil { |
| + return nil |
| + } |
| + if e := os.WriteFile(filepath.Join(leaf, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0); e != nil { |
| + return nil |
| + } |
| + if e := os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+memory"), 0); e != nil { |
| + return nil |
| + } |
| + return &Manager{root: root, logf: t.Logf} |
| +} |
| + |
| +func TestStrictKernel(t *testing.T) { |
| + mode := os.Getenv("CARLOS_STRICT_KERNEL") |
| + if mode == "" { |
| + t.Skip("requires disposable delegated user-systemd service") |
| + } |
| + m := strictKernelManager(t) |
| + if mode == "permission-denied" { |
| + if m != nil { |
| + t.Fatal("read-only cgroup delegation accepted") |
| + } |
| + if p, e := m.PrepareStrict("denied.example", TierDefault); e == nil || p != nil { |
| + t.Fatal("missing delegation became ordinary spawn") |
| + } |
| + return |
| + } |
| + if m == nil { |
| + t.Fatal("disposable service has no cgroup delegation") |
| + } |
| + if mode == "unsupported" { |
| + p, e := m.PrepareStrict("unsupported.example", TierDefault) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = p.Start(strictKernelCommand("marker")); e != ErrStrictUnsupported { |
| + t.Fatal("blocked clone3 was not unsupported", e) |
| + } |
| + if e = p.Start(strictKernelCommand("marker")); e != ErrStrictState { |
| + t.Fatal("blocked clone retried", e) |
| + } |
| + if _, e = os.Stat(filepath.Join(os.Getenv("CARLOS_STRICT_PROOF_DIR"), "marker.json")); e == nil { |
| + t.Fatal("fallback executed marker") |
| + } |
| + if e = p.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + return |
| + } |
| + t.Run("concurrent-reservation", func(t *testing.T) { |
| + type result struct { |
| + p *Prepared |
| + err error |
| + } |
| + results := make(chan result, 12) |
| + begin := make(chan struct{}) |
| + for range 12 { |
| + go func() { |
| + <-begin |
| + p, err := (&Manager{root: m.root}).PrepareStrict("concurrent.example", TierDefault) |
| + results <- result{p, err} |
| + }() |
| + } |
| + close(begin) |
| + var winner *Prepared |
| + for range 12 { |
| + r := <-results |
| + if r.err == nil { |
| + if winner != nil { |
| + t.Fatal("two concurrent preparations won") |
| + } |
| + winner = r.p |
| + } else if r.err != ErrStrictState { |
| + t.Fatal(r.err) |
| + } |
| + } |
| + if winner == nil { |
| + t.Fatal("no preparation won") |
| + } |
| + if err := winner.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("descendant-pipe-and-reservation", func(t *testing.T) { |
| + host := "tree.example" |
| + p, e := m.PrepareStrict(host, TierDefault) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + other := &Manager{root: m.root} |
| + if q, e := other.PrepareStrict(host, TierDefault); e != ErrStrictState || q != nil { |
| + t.Fatal("second manager reserved same group", e) |
| + } |
| + read, write, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer read.Close() |
| + defer write.Close() |
| + cmd := strictKernelCommand("exit-with-descendant") |
| + cmd.Stdout = write |
| + if e = p.Start(cmd); e != nil { |
| + t.Fatal(e) |
| + } |
| + write.Close() |
| + worker := strictKernelAwait(t, "worker") |
| + child := strictKernelAwait(t, "grandchild") |
| + expected := strings.TrimPrefix(m.Root(), "/sys/fs/cgroup") + "/" + ExecName(host) |
| + if worker.Group != expected || child.Group != expected || worker.Child != child.PID { |
| + t.Fatal("child escaped prepared cgroup") |
| + } |
| + if e = cmd.Wait(); e != nil { |
| + t.Fatal(e) |
| + } |
| + drained := make(chan struct{}) |
| + go func() { io.Copy(io.Discard, read); close(drained) }() |
| + select { |
| + case <-drained: |
| + t.Fatal("descendant did not retain output pipe") |
| + case <-time.After(20 * time.Millisecond): |
| + } |
| + if e = p.Close(); e != ErrStrictRefused { |
| + t.Fatal("live descendant handle discarded", e) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + if e = p.KillAndEmpty(ctx); e != nil { |
| + t.Fatal(e) |
| + } |
| + select { |
| + case <-drained: |
| + case <-ctx.Done(): |
| + t.Fatal("empty proof left descendant output blocked") |
| + } |
| + if populated, e := strictPopulation(p.events); e != nil || populated { |
| + t.Fatal("kernel population remained", e) |
| + } |
| + if e = p.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + q, e := other.PrepareStrict(host, TierDefault) |
| + if e != nil { |
| + t.Fatal("closed reservation was not released", e) |
| + } |
| + if e = q.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + }) |
| + t.Run("kernel-limit-readback", func(t *testing.T) { |
| + for i, limit := range []Limit{TierDefault, TierChildren} { |
| + p, e := m.PrepareStrict("limit"+strconv.Itoa(i)+".example", limit) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = p.limitsOK(); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = strictWrite(p.limits["memory.oom.group"], "0"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = p.Start(strictKernelCommand("marker")); e != ErrStrictRefused { |
| + t.Fatal("changed actual kernel limit reached spawn", e) |
| + } |
| + if e = p.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("permission-denied-controls", func(t *testing.T) { |
| + host := "permissions.example" |
| + p, e := m.PrepareStrict(host, TierDefault) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = p.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, name := range []string{"cgroup.kill", "cgroup.events", "memory.max"} { |
| + path := filepath.Join(m.root, ExecName(host), name) |
| + info, e := os.Stat(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = os.Chmod(path, 0); e != nil { |
| + t.Fatal(e) |
| + } |
| + candidate, prepareErr := m.PrepareStrict(host, TierDefault) |
| + if e = os.Chmod(path, info.Mode().Perm()); e != nil { |
| + t.Fatal(e) |
| + } |
| + if prepareErr == nil || candidate != nil { |
| + t.Fatal("unusable required control accepted", name) |
| + } |
| + } |
| + }) |
| + t.Run("missing-memory-controller", func(t *testing.T) { |
| + path := filepath.Join(m.root, "cgroup.subtree_control") |
| + if e := os.WriteFile(path, []byte("-memory"), 0); e != nil { |
| + t.Fatal(e) |
| + } |
| + defer func() { |
| + if e := os.WriteFile(path, []byte("+memory"), 0); e != nil { |
| + t.Error(e) |
| + } |
| + }() |
| + if p, e := m.PrepareStrict("missing.example", TierDefault); e == nil || p != nil { |
| + t.Fatal("missing memory limit interface accepted") |
| + } |
| + }) |
| +} |
| +func TestStrictKernelSupervisor(t *testing.T) { |
| + if os.Getenv("CARLOS_STRICT_SUPERVISOR") != "1" { |
| + t.Skip("disposable unit parent-death proof") |
| + } |
| + if _, e := os.Stat(filepath.Join(os.Getenv("CARLOS_STRICT_PROOF_DIR"), "supervisor.json")); e == nil { |
| + strictKernelSave(t, "restarted", 0) |
| + time.Sleep(time.Minute) |
| + return |
| + } |
| + m := strictKernelManager(t) |
| + if m == nil { |
| + t.Fatal("no delegation") |
| + } |
| + p, e := m.PrepareStrict("parent.example", TierDefault) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + cmd := strictKernelCommand("tree") |
| + read, write, e := os.Pipe() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer read.Close() |
| + cmd.Stdout = write |
| + if e = p.Start(cmd); e != nil { |
| + t.Fatal(e) |
| + } |
| + write.Close() |
| + strictKernelSave(t, "supervisor", cmd.Process.Pid) |
| + io.Copy(io.Discard, read) |
| + t.Fatal("parent-death probe returned before supervisor was killed") |
| +} |
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go| new file mode 100644 |
| index 000000000..7d365038e |
| --- /dev/null |
| +++ b/internal/cgroup/strict_linux.go |
| @@ -0,0 +1,356 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "syscall" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type strictIdentity struct{ device, inode uint64 } |
| + |
| +var strictReservations = struct { |
| + sync.Mutex |
| + held map[strictIdentity]bool |
| +}{held: make(map[strictIdentity]bool)} |
| + |
| +// Prepared pins one empty host cgroup. It grants no lease, fence or database |
| +// authority. The caller must also obtain Cmd.Wait evidence after cleanup. |
| +type Prepared struct{ *strictPreparedState } |
| + |
| +type strictPreparedState struct { |
| + mu sync.Mutex |
| + root, dir, kill, events *os.File |
| + limits map[string]*os.File |
| + expected map[string]string |
| + identity strictIdentity |
| + host string |
| + attempted, empty, closed bool |
| + start func(*exec.Cmd) error |
| +} |
| + |
| +func strictOpen(dir *os.File, name string, flags int) (*os.File, error) { |
| + fd, e := unix.Openat(int(dir.Fd()), name, flags|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return os.NewFile(uintptr(fd), name), nil |
| +} |
| +func strictID(f *os.File) (strictIdentity, error) { |
| + var st unix.Stat_t |
| + if e := unix.Fstat(int(f.Fd()), &st); e != nil { |
| + return strictIdentity{}, e |
| + } |
| + return strictIdentity{uint64(st.Dev), st.Ino}, nil |
| +} |
| +func strictRead(f *os.File) (string, error) { |
| + if _, e := f.Seek(0, 0); e != nil { |
| + return "", e |
| + } |
| + b, e := io.ReadAll(io.LimitReader(f, 4097)) |
| + if e != nil || len(b) > 4096 { |
| + return "", ErrStrictRefused |
| + } |
| + return strings.TrimSpace(string(b)), nil |
| +} |
| +func strictWrite(f *os.File, value string) error { |
| + if _, e := f.Seek(0, 0); e != nil { |
| + return e |
| + } |
| + n, e := f.WriteString(value) |
| + if e != nil { |
| + return e |
| + } |
| + if n != len(value) { |
| + return io.ErrShortWrite |
| + } |
| + return nil |
| +} |
| +func strictPopulation(f *os.File) (bool, error) { |
| + raw, e := strictRead(f) |
| + if e != nil { |
| + return false, e |
| + } |
| + seen := false |
| + populated := false |
| + for _, line := range strings.Split(raw, "\n") { |
| + parts := strings.Fields(line) |
| + if len(parts) != 2 { |
| + return false, ErrStrictRefused |
| + } |
| + if parts[0] == "populated" { |
| + if seen || (parts[1] != "0" && parts[1] != "1") { |
| + return false, ErrStrictRefused |
| + } |
| + seen = true |
| + populated = parts[1] == "1" |
| + } |
| + } |
| + if !seen { |
| + return false, ErrStrictRefused |
| + } |
| + return populated, nil |
| +} |
| +func (p *Prepared) identityOK() error { |
| + if p.closed { |
| + return ErrStrictState |
| + } |
| + id, e := strictID(p.dir) |
| + if e != nil || id != p.identity { |
| + return ErrStrictRefused |
| + } |
| + var st unix.Stat_t |
| + if e = unix.Fstatat(int(p.root.Fd()), ExecName(p.host), &st, unix.AT_SYMLINK_NOFOLLOW); e != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || (strictIdentity{uint64(st.Dev), st.Ino}) != p.identity { |
| + return ErrStrictRefused |
| + } |
| + return nil |
| +} |
| +func (p *Prepared) limitsOK() error { |
| + for name, want := range p.expected { |
| + got, e := strictRead(p.limits[name]) |
| + if e != nil || got != want { |
| + return ErrStrictRefused |
| + } |
| + } |
| + return nil |
| +} |
| +func (p *Prepared) closeFiles() { |
| + for _, f := range p.limits { |
| + f.Close() |
| + } |
| + for _, f := range []*os.File{p.events, p.kill, p.dir, p.root} { |
| + if f != nil { |
| + f.Close() |
| + } |
| + } |
| +} |
| + |
| +// PrepareStrict never uses accounting's fallback. Only the two existing tiers |
| +// are accepted; a zero or missing limit cannot create an uncapped command. |
| +func (m *Manager) PrepareStrict(host string, limit Limit) (out *Prepared, err error) { |
| + if m == nil || m.root == "" { |
| + return nil, ErrStrictUnsupported |
| + } |
| + if !controlname.ValidHostShape(host) || (limit != TierDefault && limit != TierChildren) { |
| + return nil, ErrStrictRefused |
| + } |
| + own, e := os.ReadFile("/proc/self/cgroup") |
| + rel, ok := ParseSelfCgroup(string(own)) |
| + if e != nil || !ok { |
| + return nil, ErrStrictUnsupported |
| + } |
| + current := filepath.Join("/sys/fs/cgroup", rel) |
| + rootPath := filepath.Clean(m.root) |
| + if current != rootPath && current != filepath.Join(rootPath, "supervise") { |
| + return nil, ErrStrictRefused |
| + } |
| + root, e := os.OpenFile(filepath.Clean(m.root), os.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + p := &Prepared{strictPreparedState: &strictPreparedState{root: root, host: host, limits: map[string]*os.File{}, start: func(cmd *exec.Cmd) error { return cmd.Start() }}} |
| + reserved := false |
| + defer func() { |
| + if err != nil { |
| + p.closeFiles() |
| + if reserved { |
| + strictReservations.Lock() |
| + delete(strictReservations.held, p.identity) |
| + strictReservations.Unlock() |
| + } |
| + } |
| + }() |
| + var fs unix.Statfs_t |
| + if e = unix.Fstatfs(int(root.Fd()), &fs); e != nil || uint64(fs.Type) != uint64(unix.CGROUP2_SUPER_MAGIC) { |
| + return nil, ErrStrictUnsupported |
| + } |
| + if e = unix.Mkdirat(int(root.Fd()), ExecName(host), 0755); e != nil && !errors.Is(e, unix.EEXIST) { |
| + return nil, ErrStrictRefused |
| + } |
| + p.dir, e = strictOpen(root, ExecName(host), unix.O_RDONLY|unix.O_DIRECTORY) |
| + if e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + p.identity, e = strictID(p.dir) |
| + if e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + strictReservations.Lock() |
| + if strictReservations.held[p.identity] { |
| + strictReservations.Unlock() |
| + return nil, ErrStrictState |
| + } |
| + strictReservations.held[p.identity] = true |
| + reserved = true |
| + strictReservations.Unlock() |
| + p.events, e = strictOpen(p.dir, "cgroup.events", unix.O_RDONLY) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + p.kill, e = strictOpen(p.dir, "cgroup.kill", unix.O_WRONLY) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + if populated, e := strictPopulation(p.events); e != nil || populated { |
| + return nil, ErrStrictRefused |
| + } |
| + high := highOf(limit.MemoryMax) / int64(os.Getpagesize()) * int64(os.Getpagesize()) |
| + p.expected = map[string]string{"memory.max": strconv.FormatInt(limit.MemoryMax, 10), "memory.high": strconv.FormatInt(high, 10), "memory.swap.max": strconv.FormatInt(limit.MemoryMax, 10), "memory.oom.group": "1"} |
| + for name := range p.expected { |
| + p.limits[name], e = strictOpen(p.dir, name, unix.O_RDWR) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + } |
| + for _, name := range []string{"memory.max", "memory.high", "memory.swap.max", "memory.oom.group"} { |
| + if e = strictWrite(p.limits[name], p.expected[name]); e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + } |
| + if e = p.limitsOK(); e != nil { |
| + return nil, e |
| + } |
| + if e = p.identityOK(); e != nil { |
| + return nil, e |
| + } |
| + return p, nil |
| +} |
| + |
| +// Start consumes one attempt even if exec or containment fails. The caller |
| +// supplies the complete command, including its approved executable descriptor. |
| +func (p *Prepared) Start(cmd *exec.Cmd) error { |
| + if p == nil || p.strictPreparedState == nil { |
| + return ErrStrictState |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.closed || p.attempted || p.empty { |
| + return ErrStrictState |
| + } |
| + p.attempted = true |
| + if cmd == nil || cmd.Process != nil || cmd.ProcessState != nil { |
| + return ErrStrictState |
| + } |
| + if e := p.identityOK(); e != nil { |
| + return e |
| + } |
| + if e := p.limitsOK(); e != nil { |
| + return e |
| + } |
| + if populated, e := strictPopulation(p.events); e != nil || populated { |
| + return ErrStrictRefused |
| + } |
| + setInto(cmd, int(p.dir.Fd())) |
| + cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL |
| + if e := p.start(cmd); e != nil { |
| + if shouldLatch(e) { |
| + return ErrStrictUnsupported |
| + } |
| + return ErrStrictRefused |
| + } |
| + if cmd.Process == nil { |
| + return ErrStrictRefused |
| + } |
| + return nil |
| +} |
| + |
| +// KillAndEmpty proves kernel population is zero before the caller's deadline. |
| +// Failure retains the pinned cleanup handle and permits another bounded attempt. |
| +func (p *Prepared) KillAndEmpty(ctx context.Context) error { |
| + if p == nil || p.strictPreparedState == nil { |
| + return ErrStrictState |
| + } |
| + if ctx == nil { |
| + return ErrStrictDeadline |
| + } |
| + deadline, ok := ctx.Deadline() |
| + if !ok || ctx.Err() != nil || !time.Now().Before(deadline) { |
| + return ErrStrictDeadline |
| + } |
| + lockTick := time.NewTicker(time.Millisecond) |
| + defer lockTick.Stop() |
| + for !p.mu.TryLock() { |
| + select { |
| + case <-ctx.Done(): |
| + return ErrStrictDeadline |
| + case <-lockTick.C: |
| + } |
| + } |
| + defer p.mu.Unlock() |
| + if p.closed || p.empty { |
| + return ErrStrictState |
| + } |
| + if ctx.Err() != nil { |
| + return ErrStrictDeadline |
| + } |
| + if e := p.identityOK(); e != nil { |
| + return e |
| + } |
| + p.attempted = true |
| + if e := strictWrite(p.kill, "1"); e != nil { |
| + return ErrStrictRefused |
| + } |
| + timer := time.NewTicker(5 * time.Millisecond) |
| + defer timer.Stop() |
| + for { |
| + if ctx.Err() != nil || !time.Now().Before(deadline) { |
| + return ErrStrictDeadline |
| + } |
| + if e := p.identityOK(); e != nil { |
| + return e |
| + } |
| + populated, e := strictPopulation(p.events) |
| + if e != nil { |
| + return ErrStrictRefused |
| + } |
| + if !populated { |
| + if ctx.Err() != nil || !time.Now().Before(deadline) { |
| + return ErrStrictDeadline |
| + } |
| + p.empty = true |
| + return nil |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ErrStrictDeadline |
| + case <-timer.C: |
| + } |
| + } |
| +} |
| + |
| +// Close never removes a path. A populated or unverifiable group retains its |
| +// descriptors and reservation so the same handle can still attempt cleanup. |
| +func (p *Prepared) Close() error { |
| + if p == nil || p.strictPreparedState == nil { |
| + return ErrStrictState |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.closed { |
| + return ErrStrictState |
| + } |
| + if e := p.identityOK(); e != nil { |
| + return e |
| + } |
| + if populated, e := strictPopulation(p.events); e != nil || populated { |
| + return ErrStrictRefused |
| + } |
| + p.closed = true |
| + p.closeFiles() |
| + strictReservations.Lock() |
| + delete(strictReservations.held, p.identity) |
| + strictReservations.Unlock() |
| + return nil |
| +} |
diff --git a/internal/cgroup/strict_linux_test.go b/internal/cgroup/strict_linux_test.go| new file mode 100644 |
| index 000000000..0d6124a94 |
| --- /dev/null |
| +++ b/internal/cgroup/strict_linux_test.go |
| @@ -0,0 +1,246 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "sync/atomic" |
| + "syscall" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Filesystem fixtures test handle state only. PrepareStrict must reject them; |
| +// the delegated-kernel suite separately proves actual containment and controls. |
| +func strictFixture(t *testing.T) *Prepared { |
| + t.Helper() |
| + root := t.TempDir() |
| + host := "fixture.example" |
| + dir := filepath.Join(root, ExecName(host)) |
| + if e := os.Mkdir(dir, 0700); e != nil { |
| + t.Fatal(e) |
| + } |
| + for name, value := range map[string]string{"cgroup.events": "populated 0\nfrozen 0\n", "cgroup.kill": "", "memory.max": "4096", "memory.high": "4096", "memory.swap.max": "4096", "memory.oom.group": "1"} { |
| + if e := os.WriteFile(filepath.Join(dir, name), []byte(value), 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + open := func(path string) *os.File { |
| + flags := os.O_RDWR |
| + if info, e := os.Stat(path); e == nil && info.IsDir() { |
| + flags = os.O_RDONLY |
| + } |
| + f, e := os.OpenFile(path, flags, 0) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return f |
| + } |
| + p := &Prepared{strictPreparedState: &strictPreparedState{root: open(root), dir: open(dir), host: host, limits: map[string]*os.File{}, expected: map[string]string{}, start: func(*exec.Cmd) error { return syscall.ENOSYS }}} |
| + p.kill = open(filepath.Join(dir, "cgroup.kill")) |
| + p.events = open(filepath.Join(dir, "cgroup.events")) |
| + p.identity, _ = strictID(p.dir) |
| + for _, name := range []string{"memory.max", "memory.high", "memory.swap.max", "memory.oom.group"} { |
| + p.limits[name] = open(filepath.Join(dir, name)) |
| + p.expected[name], _ = strictRead(p.limits[name]) |
| + } |
| + strictReservations.Lock() |
| + strictReservations.held[p.identity] = true |
| + strictReservations.Unlock() |
| + t.Cleanup(func() { |
| + p.closeFiles() |
| + strictReservations.Lock() |
| + delete(strictReservations.held, p.identity) |
| + strictReservations.Unlock() |
| + }) |
| + return p |
| +} |
| +func strictContext(t *testing.T, d time.Duration) context.Context { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(t.Context(), d) |
| + t.Cleanup(cancel) |
| + return ctx |
| +} |
| +func TestStrictRejectsUnavailableManagerHostAndTier(t *testing.T) { |
| + for _, m := range []*Manager{nil, {root: t.TempDir()}} { |
| + for _, host := range []string{"ok.example", "../outside", "Upper.example", "", "a/b"} { |
| + if p, e := m.PrepareStrict(host, TierDefault); e == nil || p != nil { |
| + t.Fatal("unsupported filesystem or host accepted") |
| + } |
| + } |
| + } |
| + m := &Manager{root: "/sys/fs/cgroup"} |
| + for _, limit := range []Limit{{}, {MemoryMax: -1}, {MemoryMax: 1}} { |
| + if p, e := m.PrepareStrict("x.example", limit); e == nil || p != nil { |
| + t.Fatal("missing or foreign tier accepted") |
| + } |
| + } |
| +} |
| +func TestStrictStartConsumesOneAttemptWithoutFallback(t *testing.T) { |
| + p := strictFixture(t) |
| + var calls atomic.Int32 |
| + p.start = func(cmd *exec.Cmd) error { |
| + calls.Add(1) |
| + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.UseCgroupFD || cmd.SysProcAttr.CgroupFD != int(p.dir.Fd()) || cmd.SysProcAttr.Pdeathsig != syscall.SIGKILL || !cmd.SysProcAttr.Setpgid { |
| + t.Error("strict spawn attributes missing or clobbered") |
| + } |
| + return syscall.ENOSYS |
| + } |
| + copied := *p |
| + cmd := exec.Command("/not-executed") |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
| + if e := p.Start(cmd); !errors.Is(e, ErrStrictUnsupported) { |
| + t.Fatal("unsupported clone accepted", e) |
| + } |
| + if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("second spawn accepted", e) |
| + } |
| + if e := copied.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("copied handle restored spawn authority", e) |
| + } |
| + if calls.Load() != 1 { |
| + t.Fatal("strict start retried", calls.Load()) |
| + } |
| + if raw, e := strictRead(p.kill); e != nil || raw != "" { |
| + t.Fatal("failed spawn invented cleanup", raw, e) |
| + } |
| +} |
| +func TestStrictCleanupRequiresDeadlineAndSurvivesTimeout(t *testing.T) { |
| + p := strictFixture(t) |
| + for _, ctx := range []context.Context{nil, context.Background(), strictContext(t, -time.Second)} { |
| + if e := p.KillAndEmpty(ctx); !errors.Is(e, ErrStrictDeadline) { |
| + t.Fatal("unbounded/expired cleanup accepted", e) |
| + } |
| + } |
| + if e := os.WriteFile(filepath.Join(p.root.Name(), ExecName(p.host), "cgroup.events"), []byte("populated 1\n"), 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := p.Close(); !errors.Is(e, ErrStrictRefused) { |
| + t.Fatal("populated group handle discarded", e) |
| + } |
| + if e := p.KillAndEmpty(strictContext(t, 15*time.Millisecond)); !errors.Is(e, ErrStrictDeadline) { |
| + t.Fatal("populated group claimed empty", e) |
| + } |
| + if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("cleanup timeout restored spawn authority", e) |
| + } |
| + if e := os.WriteFile(filepath.Join(p.root.Name(), ExecName(p.host), "cgroup.events"), []byte("populated 0\n"), 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := p.KillAndEmpty(strictContext(t, time.Second)); e != nil { |
| + t.Fatal("same-handle cleanup retry failed", e) |
| + } |
| + if e := p.KillAndEmpty(strictContext(t, time.Second)); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("second cleanup reused proof", e) |
| + } |
| + if e := p.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := p.Close(); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("double close accepted") |
| + } |
| + if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("closed spawn accepted") |
| + } |
| + if e := p.KillAndEmpty(strictContext(t, time.Second)); !errors.Is(e, ErrStrictState) { |
| + t.Fatal("closed cleanup accepted") |
| + } |
| +} |
| +func TestStrictCleanupMutexWaitHonorsDeadline(t *testing.T) { |
| + p := strictFixture(t) |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + start := time.Now() |
| + if e := p.KillAndEmpty(strictContext(t, 15*time.Millisecond)); !errors.Is(e, ErrStrictDeadline) { |
| + t.Fatal(e) |
| + } |
| + if time.Since(start) > 250*time.Millisecond { |
| + t.Fatal("mutex wait escaped caller deadline") |
| + } |
| +} |
| +func TestStrictStaleHandleCannotKillReplacement(t *testing.T) { |
| + p := strictFixture(t) |
| + old := filepath.Join(p.root.Name(), ExecName(p.host)) |
| + if e := os.Rename(old, old+"-old"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := os.Mkdir(old, 0700); e != nil { |
| + t.Fatal(e) |
| + } |
| + kill := filepath.Join(old, "cgroup.kill") |
| + if e := os.WriteFile(kill, []byte("replacement"), 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := p.KillAndEmpty(strictContext(t, time.Second)); !errors.Is(e, ErrStrictRefused) { |
| + t.Fatal("wrong identity accepted", e) |
| + } |
| + if e := p.Close(); !errors.Is(e, ErrStrictRefused) { |
| + t.Fatal("stale close claimed proof", e) |
| + } |
| + raw, e := os.ReadFile(kill) |
| + if e != nil || string(raw) != "replacement" { |
| + t.Fatal("replacement group touched", e) |
| + } |
| + if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictRefused) { |
| + t.Fatal("stale spawn accepted", e) |
| + } |
| +} |
| +func TestStrictChangedLimitsAndMalformedEventsRefuse(t *testing.T) { |
| + for _, name := range []string{"memory.max", "memory.high", "memory.swap.max", "memory.oom.group", "cgroup.events"} { |
| + t.Run(name, func(t *testing.T) { |
| + p := strictFixture(t) |
| + if e := os.WriteFile(filepath.Join(p.root.Name(), ExecName(p.host), name), []byte("missing\n"), 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + called := false |
| + p.start = func(*exec.Cmd) error { called = true; return nil } |
| + if e := p.Start(exec.Command("/not-executed")); e == nil || called { |
| + t.Fatal("unchecked controls reached spawn", e) |
| + } |
| + if name == "cgroup.events" { |
| + if e := p.Close(); e == nil { |
| + t.Fatal("unknown emptiness discarded handle") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStrictConcurrentAndInvalidStartsConsumeAttempt(t *testing.T) { |
| + p := strictFixture(t) |
| + var calls atomic.Int32 |
| + p.start = func(*exec.Cmd) error { calls.Add(1); return syscall.ENOSYS } |
| + results := make(chan error, 16) |
| + for range 16 { |
| + go func() { results <- p.Start(exec.Command("/not-executed")) }() |
| + } |
| + unsupported := 0 |
| + for range 16 { |
| + switch err := <-results; err { |
| + case ErrStrictUnsupported: |
| + unsupported++ |
| + case ErrStrictState: |
| + default: |
| + t.Fatal(err) |
| + } |
| + } |
| + if calls.Load() != 1 || unsupported != 1 { |
| + t.Fatal("concurrent starts repeated attempt") |
| + } |
| + q := strictFixture(t) |
| + if err := q.Start(nil); err != ErrStrictState { |
| + t.Fatal(err) |
| + } |
| + if err := q.Start(exec.Command("/not-executed")); err != ErrStrictState { |
| + t.Fatal("invalid command restored attempt", err) |
| + } |
| + for _, zero := range []*Prepared{nil, {}} { |
| + if zero.Start(nil) != ErrStrictState || zero.Close() != ErrStrictState || zero.KillAndEmpty(context.Background()) != ErrStrictState { |
| + t.Fatal("zero handle accepted") |
| + } |
| + } |
| +} |
diff --git a/internal/cgroup/strict_other.go b/internal/cgroup/strict_other.go| new file mode 100644 |
| index 000000000..0a1b0ed76 |
| --- /dev/null |
| +++ b/internal/cgroup/strict_other.go |
| @@ -0,0 +1,17 @@ |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "os/exec" |
| +) |
| + |
| +type Prepared struct{} |
| + |
| +func (m *Manager) PrepareStrict(host string, limit Limit) (*Prepared, error) { |
| + return nil, ErrStrictUnsupported |
| +} |
| +func (p *Prepared) Start(cmd *exec.Cmd) error { return ErrStrictUnsupported } |
| +func (p *Prepared) KillAndEmpty(ctx context.Context) error { return ErrStrictUnsupported } |
| +func (p *Prepared) Close() error { return ErrStrictUnsupported } |