//go:build linux package cgroup import ( "context" "errors" "os" "os/exec" "path/filepath" "testing" "time" "golang.org/x/sys/unix" ) func makeRoleFixture(t *testing.T, p *Prepared, role, events string) { t.Helper() d := filepath.Join(p.dir.Name(), "role-"+role) if err := os.Mkdir(d, 0700); err != nil { t.Fatal(err) } for n, v := range map[string]string{"cgroup.events": events, "cgroup.kill": ""} { if err := os.WriteFile(filepath.Join(d, n), []byte(v), 0600); err != nil { t.Fatal(err) } } } func TestPrepareRoleChildEmptyAndIdentity(t *testing.T) { p := strictFixture(t) makeRoleFixture(t, p, "seed", "populated 0\n") child, err := p.PrepareRoleChild("seed") if err != nil { t.Fatal(err) } flags, err := unix.FcntlInt(child.entryParent.Fd(), unix.F_GETFD, 0) if err != nil || flags&unix.FD_CLOEXEC == 0 { t.Fatalf("parent descriptor may escape exec: flags=%d err=%v", flags, err) } id, err := child.Identity() if err != nil || id.Device == 0 || id.Inode == 0 { t.Fatalf("identity=%+v %v", id, err) } if err := child.Close(); err != nil { t.Fatal(err) } } func TestRoleChildCannotOutliveParentCleanup(t *testing.T) { for _, action := range []string{"kill", "close", "failed-kill", "failed-close"} { t.Run(action, func(t *testing.T) { p := strictFixture(t) makeRoleFixture(t, p, "seed", "populated 0\n") child, err := p.PrepareRoleChild("seed") if err != nil { t.Fatal(err) } defer child.Close() starts := 0 child.start = func(*exec.Cmd) error { starts++; return nil } if action == "failed-close" { if err := strictWrite(p.events, "populated 1\n"); err != nil { t.Fatal(err) } if err := p.Close(); !errors.Is(err, ErrStrictRefused) { t.Fatalf("failed close=%v", err) } // Even once the parent's population drops, cleanup remains latched. if err := strictWrite(p.events, "populated 0\n"); err != nil { t.Fatal(err) } } else if action == "close" { if err := p.Close(); err != nil { t.Fatal(err) } } else { if action == "failed-kill" { p.kill.Close() } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() err := p.KillAndEmpty(ctx) if (err != nil) != (action == "failed-kill") { t.Fatalf("cleanup: %v", err) } } if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictState) || starts != 0 { t.Fatalf("child reopened drained parent: %v, starts=%d", err, starts) } if _, err := p.PrepareRoleChild("another"); !errors.Is(err, ErrStrictState) { t.Fatalf("new role after cleanup: %v", err) } }) } } func TestRoleChildRechecksParentLimits(t *testing.T) { p := strictFixture(t) makeRoleFixture(t, p, "seed", "populated 0\n") child, err := p.PrepareRoleChild("seed") if err != nil { t.Fatal(err) } defer child.Close() if err := strictWrite(p.limits["memory.max"], "0"); err != nil { t.Fatal(err) } if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictRefused) { t.Fatalf("changed aggregate limit: %v", err) } } func TestPrepareRoleChildRefusals(t *testing.T) { t.Run("nonempty", func(t *testing.T) { p := strictFixture(t) makeRoleFixture(t, p, "seed", "populated 1\n") if _, err := p.PrepareRoleChild("seed"); !errors.Is(err, ErrStrictRefused) { t.Fatalf("err=%v", err) } }) t.Run("symlink", func(t *testing.T) { p := strictFixture(t) if err := os.Symlink(t.TempDir(), filepath.Join(p.dir.Name(), "role-seed")); err != nil { t.Fatal(err) } if _, err := p.PrepareRoleChild("seed"); err == nil { t.Fatal("symlink accepted") } }) t.Run("no-delegation", func(t *testing.T) { p := strictFixture(t) if _, err := p.PrepareRoleChild("seed"); !errors.Is(err, ErrStrictUnsupported) { t.Fatalf("err=%v", err) } }) t.Run("absent-role", func(t *testing.T) { p := strictFixture(t) if _, err := p.PrepareRoleChild(""); !errors.Is(err, ErrStrictRefused) { t.Fatalf("err=%v", err) } }) } func TestRoleChildRefusesAfterCleanupAcquisitionCancellation(t *testing.T) { p := strictFixture(t) makeRoleFixture(t, p, "seed", "populated 0\n") child, err := p.PrepareRoleChild("seed") if err != nil { t.Fatal(err) } defer child.Close() starts := 0 child.start = func(*exec.Cmd) error { starts++; return nil } p.mu.Lock() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() done := make(chan error, 1) go func() { done <- p.KillAndEmpty(ctx) }() err = <-done p.mu.Unlock() if !errors.Is(err, ErrStrictDeadline) { t.Fatalf("cleanup acquisition: %v", err) } if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictState) || starts != 0 { t.Fatalf("child reopened parent after cancelled cleanup: %v, starts=%d", err, starts) } if _, err := p.PrepareRoleChild("another"); !errors.Is(err, ErrStrictState) { t.Fatalf("new role after cancelled cleanup: %v", err) } } // TestPrepareRoleChildWithIntentRefusesExistingDirectory verifies that the intent // path refuses with ErrStrictRefused when the child directory appears between the // intent callback and mkdir (i.e., the intent callback creates the directory). func TestPrepareRoleChildWithIntentRefusesExistingDirectory(t *testing.T) { p := strictFixture(t) intentCalled := false intent := func(id ContainmentIdentity) error { intentCalled = true // Simulate another actor creating the directory after our intent checks for it // but before mkdir is called if err := os.Mkdir(filepath.Join(p.dir.Name(), "role-newchild"), 0700); err != nil { t.Fatal(err) } // Also create the required cgroup files for n, v := range map[string]string{"cgroup.events": "populated 0\n", "cgroup.kill": ""} { if err := os.WriteFile(filepath.Join(p.dir.Name(), "role-newchild", n), []byte(v), 0600); err != nil { t.Fatal(err) } } return nil } child, err := p.PrepareRoleChildWithIntent("newchild", intent) if err != nil && !intentCalled { t.Fatalf("intent was not called before error: %v", err) } if !intentCalled { t.Fatal("intent callback was not called") } if !errors.Is(err, ErrStrictRefused) || child != nil { t.Fatalf("intent path did not refuse pre-existing directory: err=%v child=%v", err, child) } }