carlos / platform Public

host, safefile: off-cloud credentials are root:carlos 0640 (created and repaired); the registry child is non-dumpable; stale safefile staging directories are swept (#267)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 9a0f80f3295545dc196bbede0e99296ceb51001b parent 2d3ff96
22 files changed, +959 −39
  • internal/host/agent.go +7 −0
  • internal/host/bootstrap.go +9 −24
  • internal/host/claimroleassembly_linux.go +1 −1
  • internal/host/claimroleassembly_linux_test.go +53 −0
  • internal/host/host_test.go +34 −10
  • internal/host/offcloudcredentials_linux.go +95 −0
  • internal/host/offcloudcredentials_linux_test.go +123 −0
  • internal/host/offcloudcredentials_other.go +16 −0
  • internal/host/registrychild_exec_linux.go +29 −0
  • internal/host/registrychild_exec_linux_test.go +56 −0
  • internal/host/registrychild_exec_other.go +8 −0
  • internal/host/registrychild_unix.go +4 −1
  • internal/host/stagingcleanup.go +32 −0
  • internal/host/stagingcleanup_linux_test.go +74 −0
  • internal/host/systemapply.go +1 −0
  • internal/host/units/carlos-system-recover.service +1 −1
  • internal/host/units/carlos-system-update.service +1 −1
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/registry/remote.go +1 −0
  • internal/safefile/reap_linux.go +177 −0
  • internal/safefile/reap_linux_test.go +230 −0
  • internal/safefile/reap_other.go +6 −0

CI passed — run details

diff --git a/internal/host/agent.go b/internal/host/agent.go
index 72d832150..e6a33008a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -3925,6 +3925,13 @@ func runAdopt(args []string, buildClaims ClaimFactory) {
// runAdoptWithEnsureSystemUnits keeps the root-only unit effect injectable;
// the rest of adopt startup, including its fatal registry diagnostics, is real.
func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func()) {
+ reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf)
+ // claimEntryContext may open the AWS store, whose SDK accepts credential_process.
+ if os.Geteuid() == 0 {
+ if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
+ log.Fatalf("adopt: offcloud credentials: %v", err)
+ }
+ }
claimCtx, err := claimEntryContext(context.Background(), os.Getenv, openDeployStore)
if err != nil {
log.Fatalf("adopt: host claims: %v", err)
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 4ed7efbf8..86045311d 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -329,27 +329,20 @@ func (f *repeatableFlag) Set(v string) error {
// wrong-region-fails-at-IAM holds off-cloud too. The flag is explicit; no
// IMDS sniffing (the agent's one IMDS probe is already gated for non-EC2).
//
-// The credential check runs BEFORE any system mutation: a failed precondition
-// leaves the box untouched, same as a failed bootstrap() would.
+// Credential shape and mode checks precede bootstrap mutations. A rejected
+// credential precondition leaves the box untouched.
func bootstrapOffcloud(root string, consumers []string) error {
cred := filepath.Join(root, "etc", "carlos", "credentials")
- fi, err := os.Stat(cred)
+ fi, err := checkOffcloudCredentials(cred)
if err != nil {
return fmt.Errorf("offcloud: stage the scoped credentials file first: %s (0600, see infra/remote-host.md): %w", cred, err)
}
- if fi.Mode().Perm() != 0o600 {
- return fmt.Errorf("offcloud: %s must be mode 0600, is %o", cred, fi.Mode().Perm())
+ if fi.Mode().Perm() != 0o600 && fi.Mode().Perm() != 0o640 {
+ return fmt.Errorf("offcloud: %s must be mode 0600 or 0640, is %o", cred, fi.Mode().Perm())
}
if err := bootstrap(root, consumers); err != nil {
return err
}
- // Ownership is a live-box-only step, same as ensureServiceUser/chownToService
- // in bootstrap() above — a staging root has no service user to chown to.
- if filepath.Clean(root) == "/" {
- if err := chownToService(cred); err != nil { // same helper the data dirs use
- return err
- }
- }
fmt.Printf("offcloud bootstrap complete.\n ensure /etc/carlos/host.env sets:\n AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials\n then stage /opt/carlos/carlos and start carlos-edge.service (see infra/remote-host.md)\n")
return nil
}
@@ -601,6 +594,10 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error {
return nil
}
+ // Repair before arming any root pass that may load these credentials.
+ if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
+ return err
+ }
for _, args := range systemctlPlan(enabledUnits) {
if err := systemctl(args...); err != nil {
return err
@@ -728,18 +725,6 @@ func bootstrapOwner(owner, group string) (int, int, error) {
return uid, gid, err
}
-func chownToService(path string) error {
- uid, gid, err := bootstrapOwner(serviceUser, serviceUser)
- if err != nil {
- return err
- }
- _, err = ownRegistryFile(path, uid, gid)
- if err != nil {
- log.Printf("ALERT: bootstrap: refusing ownership of %s: %v", path, err)
- }
- return err
-}
-
func systemctl(args ...string) error {
out, err := exec.Command("systemctl", args...).CombinedOutput()
if err != nil {
diff --git a/internal/host/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go
index 0663c0817..ac9baee27 100644
--- a/internal/host/claimroleassembly_linux.go
+++ b/internal/host/claimroleassembly_linux.go
@@ -206,7 +206,7 @@ func verifiedClaimExecutable(ctx context.Context, cmd *exec.Cmd, path, digest st
if err = unix.Fstat(fd, &before); err != nil {
return refuse(err)
}
- if before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 {
+ if before.Uid != 0 || before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 {
return refuse(hostbox.ErrChanged)
}
hash := sha256.New()
diff --git a/internal/host/claimroleassembly_linux_test.go b/internal/host/claimroleassembly_linux_test.go
index 3c8d5b2fa..1bad58bed 100644
--- a/internal/host/claimroleassembly_linux_test.go
+++ b/internal/host/claimroleassembly_linux_test.go
@@ -11,9 +11,14 @@ import (
"os/exec"
"path/filepath"
"testing"
+
+ "golang.org/x/sys/unix"
)
func TestClaimExecutablePinsVerifiedBytes(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root to create and replace a trusted executable")
+ }
path := filepath.Join(t.TempDir(), "binary")
body := []byte("approved executable bytes")
if err := os.WriteFile(path, body, 0555); err != nil {
@@ -40,6 +45,54 @@ func TestClaimExecutablePinsVerifiedBytes(t *testing.T) {
t.Fatal("changed bytes admitted")
}
}
+
+func TestClaimExecutableRefusesNonRootOwner(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "binary")
+ body := []byte("approved executable bytes")
+ if err := os.WriteFile(path, body, 0555); err != nil {
+ t.Fatal(err)
+ }
+ if os.Geteuid() == 0 {
+ if err := os.Chown(path, 65534, 65534); err != nil {
+ t.Fatal(err)
+ }
+ }
+ digest := sha256.Sum256(body)
+ if f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])); err == nil {
+ f.Close()
+ t.Fatal("non-root-owned executable accepted")
+ }
+}
+
+func TestClaimExecutableAcceptsRootOwnedSystemBinary(t *testing.T) {
+ path, err := filepath.EvalSymlinks("/usr/bin/true")
+ if err != nil {
+ t.Skipf("requires a root-owned system executable: %v", err)
+ }
+ var st unix.Stat_t
+ if err := unix.Stat(path, &st); err != nil || st.Uid != 0 || st.Nlink != 1 || st.Mode&0022 != 0 {
+ t.Skip("requires a root-owned single-link system executable")
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest := sha256.Sum256(body)
+ f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ got, err := io.ReadAll(f)
+ if err != nil || string(got) != string(body) {
+ t.Fatalf("pinned system executable differs: %v", err)
+ }
+ digest[0] ^= 1
+ if f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])); err == nil {
+ f.Close()
+ t.Fatal("wrong system executable digest accepted")
+ }
+}
func TestClaimExecutableRefusesSymlinkAndWritableArtifact(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "binary")
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 2313b98e3..c84c8f20a 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -1167,7 +1167,7 @@ func TestBootstrapPrivilegedPlanEnrollsConsumers(t *testing.T) {
// (issue #53 point 4): a box with no EC2 instance profile must have its scoped
// credentials staged — and staged at 0600 — before bootstrap proceeds. This is
// the staging-root path (no root privileges available in tests), so the
-// service-user chown below never runs; that only happens on a live root.
+// root:service ownership repair never runs; that only happens on a live root.
func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) {
root := t.TempDir()
if err := bootstrapOffcloud(root, nil); err == nil {
@@ -1802,14 +1802,18 @@ func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) {
floor, verdict string // "" means no file
noPrev bool
want string
+ floorKind string
}{
- {"no journal", false, "", "", false, "carlos"},
- {"no floor", true, "", "", false, "carlos.prev"},
- {"no floor, no prev", true, "", "", true, "carlos"},
- {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev"},
- {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos"},
- {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos"},
- {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos"},
+ {"no journal", false, "", "", false, "carlos", ""},
+ {"no floor", true, "", "", false, "carlos.prev", ""},
+ {"no floor, no prev", true, "", "", true, "carlos", ""},
+ {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev", ""},
+ {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos", ""},
+ {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos", ""},
+ {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos", ""},
+ {"symlink floor with matching verdict", true, "v0.30.0\n", "v0.30.0\n", false, "carlos", "symlink"},
+ {"dangling symlink floor", true, "", "", false, "carlos", "dangling"},
+ {"directory floor", true, "", "", false, "carlos", "directory"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
@@ -1827,8 +1831,28 @@ func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) {
if tc.journal {
os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
}
- if tc.floor != "" {
- os.WriteFile(filepath.Join(data, moves.FloorMinReleaseFile), []byte(tc.floor), 0o644)
+ floorPath := filepath.Join(data, moves.FloorMinReleaseFile)
+ switch tc.floorKind {
+ case "symlink", "dangling":
+ target := filepath.Join(root, "floor-target")
+ if tc.floorKind == "symlink" {
+ if err := os.WriteFile(target, []byte(tc.floor), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Symlink(target, floorPath); err != nil {
+ t.Fatal(err)
+ }
+ case "directory":
+ if err := os.Mkdir(floorPath, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ default:
+ if tc.floor != "" {
+ if err := os.WriteFile(floorPath, []byte(tc.floor), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
}
if tc.verdict != "" {
os.WriteFile(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.verdict), 0o644)
diff --git a/internal/host/offcloudcredentials_linux.go b/internal/host/offcloudcredentials_linux.go
new file mode 100644
index 000000000..b5bfafd5f
--- /dev/null
+++ b/internal/host/offcloudcredentials_linux.go
@@ -0,0 +1,95 @@
+//go:build linux
+
+package host
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "os/user"
+ "strconv"
+
+ "golang.org/x/sys/unix"
+)
+
+// openOffcloudCredentials pins the parent and refuses links and special files
+// before either ownership or mode is changed. O_PATH inspects special files
+// without opening a device or blocking on a planted FIFO.
+func openOffcloudCredentials(path string) (_ *os.File, err error) {
+ defer func() {
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err)
+ }
+ }()
+ parent, name, err := openRegistryParent(path)
+ if err != nil {
+ return nil, err
+ }
+ defer parent.Close()
+ fd, err := unix.Openat2(int(parent.Fd()), name, &unix.OpenHow{
+ Flags: unix.O_PATH | unix.O_NOFOLLOW | unix.O_CLOEXEC,
+ Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
+ })
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), path)
+ defer f.Close()
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return nil, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 {
+ return nil, fmt.Errorf("not a plain single-link regular file")
+ }
+ // Reopen the pinned regular inode, never the caller's mutable pathname.
+ opened, err := os.Open(fmt.Sprintf("/proc/self/fd/%d", fd))
+ if err != nil {
+ return nil, err
+ }
+ return opened, nil
+}
+
+func checkOffcloudCredentials(path string) (os.FileInfo, error) {
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return f.Stat()
+}
+
+// Root's SDK accepts credential_process from this file. The service may read
+// the credentials, but must never own or write them. Missing means a cloud box.
+func repairOffcloudCredentials(path string) error {
+ f, err := openOffcloudCredentials(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ g, err := user.LookupGroup(serviceUser)
+ if err != nil {
+ return err
+ }
+ gid, err := strconv.Atoi(g.Gid)
+ if err != nil {
+ return err
+ }
+ return secureOffcloudCredentials(f, gid)
+}
+
+func secureOffcloudCredentials(f *os.File, gid int) error {
+ if err := unix.Fchown(int(f.Fd()), 0, gid); err != nil {
+ log.Printf("ALERT: offcloud credentials ownership repair %s: %v", f.Name(), err)
+ return err
+ }
+ if err := unix.Fchmod(int(f.Fd()), 0o640); err != nil {
+ log.Printf("ALERT: offcloud credentials mode repair %s: %v", f.Name(), err)
+ return err
+ }
+ return nil
+}
diff --git a/internal/host/offcloudcredentials_linux_test.go b/internal/host/offcloudcredentials_linux_test.go
new file mode 100644
index 000000000..6b5801878
--- /dev/null
+++ b/internal/host/offcloudcredentials_linux_test.go
@@ -0,0 +1,123 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestOffcloudCredentialsCreationAndRepair(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("root required for root:service ownership")
+ }
+ const serviceID = 65534
+ for _, owner := range []int{0, serviceID} {
+ t.Run(map[int]string{0: "creation", serviceID: "repair"}[owner], func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "credentials")
+ contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n")
+ if err := os.WriteFile(path, contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(path, owner, serviceID); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = secureOffcloudCredentials(f, serviceID)
+ f.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st unix.Stat_t
+ if err := unix.Lstat(path, &st); err != nil {
+ t.Fatal(err)
+ }
+ if st.Uid != 0 || st.Gid != serviceID || st.Mode&0o7777 != 0o640 {
+ t.Fatalf("got owner %d:%d mode %o", st.Uid, st.Gid, st.Mode&0o7777)
+ }
+ }
+ if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) {
+ t.Fatalf("credentials changed: %q, %v", got, err)
+ }
+ })
+ }
+}
+
+func TestOffcloudCredentialsRefuseLinksAndSpecials(t *testing.T) {
+ for _, kind := range []string{"symlink", "dangling", "hardlink", "fifo", "directory", "parent-symlink"} {
+ t.Run(kind, func(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "credentials")
+ victim := filepath.Join(root, "victim")
+ if err := os.WriteFile(victim, []byte("preserve"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var err error
+ switch kind {
+ case "symlink":
+ err = os.Symlink(victim, path)
+ case "dangling":
+ err = os.Symlink(filepath.Join(root, "absent"), path)
+ case "hardlink":
+ err = os.Link(victim, path)
+ case "fifo":
+ err = unix.Mkfifo(path, 0o600)
+ case "directory":
+ err = os.Mkdir(path, 0o600)
+ case "parent-symlink":
+ err = os.Symlink(root, filepath.Join(root, "linked-parent"))
+ path = filepath.Join(root, "linked-parent", "victim")
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var logs bytes.Buffer
+ previous := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(previous)
+ if err := repairOffcloudCredentials(path); err == nil {
+ t.Fatal("accepted unsafe credentials")
+ }
+ if !strings.Contains(logs.String(), "ALERT:") {
+ t.Fatalf("refusal did not alert: %s", logs.String())
+ }
+ fi, err := os.Stat(victim)
+ if err != nil || fi.Mode().Perm() != 0o600 {
+ t.Fatalf("victim mode changed: %v, %v", fi, err)
+ }
+ if got, err := os.ReadFile(victim); err != nil || string(got) != "preserve" {
+ t.Fatalf("victim changed: %q, %v", got, err)
+ }
+ })
+ }
+}
+
+func TestOffcloudCredentialsMissingCloudFile(t *testing.T) {
+ if err := repairOffcloudCredentials(filepath.Join(t.TempDir(), "absent", "credentials")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBootstrapOffcloudAcceptsRepairedCredentials(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "etc", "carlos", "credentials")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("[default]\n"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := bootstrapOffcloud(root, nil); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/host/offcloudcredentials_other.go b/internal/host/offcloudcredentials_other.go
new file mode 100644
index 000000000..2af2da277
--- /dev/null
+++ b/internal/host/offcloudcredentials_other.go
@@ -0,0 +1,16 @@
+//go:build !linux
+
+package host
+
+import (
+ "fmt"
+ "os"
+)
+
+func checkOffcloudCredentials(string) (os.FileInfo, error) {
+ return nil, fmt.Errorf("offcloud credentials operations require Linux")
+}
+
+func repairOffcloudCredentials(string) error {
+ return fmt.Errorf("offcloud credentials operations require Linux")
+}
diff --git a/internal/host/registrychild_exec_linux.go b/internal/host/registrychild_exec_linux.go
index 84e324f62..f5d101c28 100644
--- a/internal/host/registrychild_exec_linux.go
+++ b/internal/host/registrychild_exec_linux.go
@@ -3,8 +3,10 @@
package host
import (
+ "fmt"
"os"
"os/exec"
+ "runtime"
"strconv"
"golang.org/x/sys/unix"
@@ -22,9 +24,36 @@ func registryChildExec(path string, uid, gid int) (*exec.Cmd, error) {
}
func registryChildCloseExec() error {
+ // This marks descriptors in the parent too; Go opens its own descriptors
+ // close-on-exec, and ExtraFiles explicitly preserves the pinned executable.
return unix.CloseRange(3, ^uint(0), unix.CLOSE_RANGE_CLOEXEC)
}
+func registryChildStart(cmd *exec.Cmd) error {
+ started := make(chan error, 1)
+ go func() {
+ runtime.LockOSThread()
+ // Never unlock: exiting this goroutine retires this OS thread. The
+ // parent keeps its privileges; every thread in the exec'd child inherits
+ // no-new-privileges, including threads created by cgo or the Go runtime.
+ if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
+ started <- fmt.Errorf("registry: no new privileges: %w", err)
+ return
+ }
+ started <- cmd.Start()
+ }()
+ return <-started
+}
+
+func registryChildHarden() error {
+ // Exec resets dumpability after setuid. Reset it before arguments or any
+ // service-controlled input are read, blocking same-uid ptrace/proc access.
+ if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
+ return fmt.Errorf("registry: disable dumpability: %w", err)
+ }
+ return nil
+}
+
func closeRegistryExecutable() {
// fd 3 is the parent's pinned executable, passed only for exec.
unix.Close(3)
diff --git a/internal/host/registrychild_exec_linux_test.go b/internal/host/registrychild_exec_linux_test.go
index 913f55656..ff762b166 100644
--- a/internal/host/registrychild_exec_linux_test.go
+++ b/internal/host/registrychild_exec_linux_test.go
@@ -3,11 +3,13 @@
package host
import (
+ "bytes"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
+ "runtime"
"strconv"
"strings"
"testing"
@@ -16,6 +18,60 @@ import (
"golang.org/x/sys/unix"
)
+func TestRegistryChildHardeningHelper(t *testing.T) {
+ if os.Args[len(os.Args)-1] != "--hardening-probe" {
+ return
+ }
+ // Malformed input must still leave the child non-dumpable.
+ if err := RunRegistryChild(nil); err == nil {
+ t.Fatal("invalid arguments accepted")
+ }
+ dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
+ if err != nil || dumpable != 0 {
+ t.Fatalf("dumpable = %d, %v", dumpable, err)
+ }
+ threads, err := os.ReadDir("/proc/self/task")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, thread := range threads {
+ status, err := os.ReadFile(filepath.Join("/proc/self/task", thread.Name(), "status"))
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil || !bytes.Contains(status, []byte("NoNewPrivs:\t1\n")) {
+ t.Fatalf("thread %s lacks no-new-privileges: %v", thread.Name(), err)
+ }
+ }
+}
+
+func TestRegistryChildHardening(t *testing.T) {
+ // Keep this thread pinned while verifying the spawn did not change it.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ before, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(executable, "-test.run=^TestRegistryChildHardeningHelper$", "--", "--hardening-probe")
+ var output bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &output, &output
+ if err := registryChildStart(cmd); err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Wait(); err != nil {
+ t.Fatalf("hardening probe: %v: %s", err, output.String())
+ }
+ after, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)
+ if err != nil || after != before {
+ t.Fatalf("parent no-new-privileges changed from %d to %d: %v", before, after, err)
+ }
+}
+
func TestRegistryChildFDHelper(t *testing.T) {
if len(os.Args) == 0 || os.Args[len(os.Args)-1] != "--fd-probe" {
return
diff --git a/internal/host/registrychild_exec_other.go b/internal/host/registrychild_exec_other.go
index 1b1ec6472..52e9f696f 100644
--- a/internal/host/registrychild_exec_other.go
+++ b/internal/host/registrychild_exec_other.go
@@ -15,4 +15,12 @@ func registryChildCloseExec() error {
return errors.New("registry: privileged child requires Linux")
}
+func registryChildStart(cmd *exec.Cmd) error {
+ return errors.New("registry: privileged child requires Linux")
+}
+
+func registryChildHarden() error {
+ return errors.New("registry: privileged child requires Linux")
+}
+
func closeRegistryExecutable() {}
diff --git a/internal/host/registrychild_unix.go b/internal/host/registrychild_unix.go
index ab98a09eb..75e938aa9 100644
--- a/internal/host/registrychild_unix.go
+++ b/internal/host/registrychild_unix.go
@@ -76,7 +76,7 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo
input.Close()
return nil, nil, nil, err
}
- if err = cmd.Start(); err != nil {
+ if err = registryChildStart(cmd); err != nil {
input.Close()
output.Close()
return nil, nil, nil, err
@@ -86,6 +86,9 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo
// RunRegistryChild is the private re-exec entry point, before CLI dispatch.
func RunRegistryChild(args []string) error {
+ if err := registryChildHarden(); err != nil {
+ return err
+ }
closeRegistryExecutable()
readOnly := len(args) == 4 && args[3] == "read-only"
if len(args) != 3 && !readOnly {
diff --git a/internal/host/stagingcleanup.go b/internal/host/stagingcleanup.go
new file mode 100644
index 000000000..8f631e04d
--- /dev/null
+++ b/internal/host/stagingcleanup.go
@@ -0,0 +1,32 @@
+package host
+
+import (
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+// reapStagingWrites runs before a pass needs free space for its own writes.
+// Production callers run as root, so service-owned scratch is never removed.
+func reapStagingWrites(opt, data, etc string, logf func(string, ...any)) {
+ roots := []string{opt, data}
+ if opt != "" {
+ // Descendant traversal refuses mount crossings. Open known writer
+ // roots separately too, for boxes with a dedicated versions disk.
+ roots = append(roots, filepath.Join(opt, "versions"), filepath.Join(opt, "staging"),
+ filepath.Join(systemRootDir(opt), "staging"))
+ }
+ if etc != "" {
+ roots = append(roots, filepath.Join(etc, "carlos"), filepath.Join(etc, "systemd", "system"),
+ filepath.Join(etc, "systemd", "journald.conf.d"), filepath.Join(etc, "sysusers.d"),
+ filepath.Join(etc, "tmpfiles.d"))
+ }
+ for _, root := range roots {
+ if root == "" {
+ continue
+ }
+ if err := safefile.Reap(root); err != nil {
+ logf("staging cleanup: %s: %v", root, err)
+ }
+ }
+}
diff --git a/internal/host/stagingcleanup_linux_test.go b/internal/host/stagingcleanup_linux_test.go
new file mode 100644
index 000000000..fc76e499f
--- /dev/null
+++ b/internal/host/stagingcleanup_linux_test.go
@@ -0,0 +1,74 @@
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSystemApplyReapsStagingBeforeLockCreation(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ roots := []string{
+ f.a.optDir,
+ filepath.Join(f.a.optDir, "versions", "acct", "v1"),
+ f.a.stagingDir(),
+ filepath.Join(f.a.dataDir, "releases", "healthy"),
+ filepath.Join(f.a.etcDir, "carlos"),
+ filepath.Join(f.a.etcDir, "systemd", "system"),
+ filepath.Join(f.a.etcDir, "systemd", "journald.conf.d"),
+ filepath.Join(f.a.etcDir, "sysusers.d"),
+ filepath.Join(f.a.etcDir, "tmpfiles.d"),
+ }
+ var stale, retained []string
+ for _, root := range roots {
+ for _, tc := range []struct {
+ name string
+ old bool
+ }{
+ {".carlos-write-" + strings.Repeat("a", 32), true},
+ {".carlos-write-" + strings.Repeat("b", 32), false},
+ {".carlos-write-unrelated", true},
+ } {
+ path := filepath.Join(root, tc.name)
+ if err := os.MkdirAll(path, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(path, "data"), []byte("interrupted"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.old {
+ old := time.Now().Add(-2 * time.Hour)
+ for _, entry := range []string{filepath.Join(path, "data"), path} {
+ if err := os.Chtimes(entry, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if tc.old && strings.HasSuffix(tc.name, strings.Repeat("a", 32)) {
+ stale = append(stale, path)
+ } else {
+ retained = append(retained, path)
+ }
+ }
+ }
+ // Fail at lock creation, proving cleanup needs neither a usable lock nor
+ // a successful recovery (which independently removes system staging).
+ if err := os.Mkdir(f.a.lockPath(), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.Run(t.Context(), false); err == nil {
+ t.Fatal("directory accepted as lock file")
+ }
+ for _, path := range stale {
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Errorf("stale staging remains at %s: %v", path, err)
+ }
+ }
+ for _, path := range retained {
+ if _, err := os.Stat(filepath.Join(path, "data")); err != nil {
+ t.Errorf("retained staging changed at %s: %v", path, err)
+ }
+ }
+}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 73a8902e9..9825fb1b3 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -256,6 +256,7 @@ func (a *systemApplier) file(name string) string { return filepath.Join(a.rootDi
// journal is closed, and re-firing OnFailure= over a finished recovery
// would only start it again with nothing left to do.
func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) {
+ reapStagingWrites(a.optDir, a.dataDir, a.etcDir, a.ops.logf)
if err := os.MkdirAll(a.rootDir(), 0o755); err != nil {
return err
}
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service
index 3da2b581b..ea6465c10 100644
--- a/internal/host/units/carlos-system-recover.service
+++ b/internal/host/units/carlos-system-recover.service
@@ -26,7 +26,7 @@ DefaultDependencies=yes
Type=oneshot
TimeoutStartSec=10min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
ProtectHome=yes
PrivateTmp=yes
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service
index 9bc8d25f9..98b61651c 100644
--- a/internal/host/units/carlos-system-update.service
+++ b/internal/host/units/carlos-system-update.service
@@ -32,7 +32,7 @@ OnFailure=carlos-system-recover.service
Type=oneshot
TimeoutStartSec=15min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
# --- hardening (the carlos-stage.service set) ---
ProtectHome=yes
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index b7758dd7c..e619c2ca9 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -621,7 +621,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/fleetapi/fleetapi.go", "store.Put", "record-transport", "def:Put", "68f7424cbbaf8fffde48429ae29198cf6e4fcf768c512657b305224febc11186"},
{"internal/genselect/select.go", "requestLocation", "key-site", "lit:instances", "12ef5b8f94be7575ec7ab5f169736320ee6938440387fe5ea99b96f2a528cca0"},
{"internal/host/bootstrap.go", "const tmpfilesConf", "key-site", "lit:instances", "71d83f240cb1c3ab6f9dd2954b1f3e93b90133362ec12193746f60e75aeced7d"},
- {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "f75c2a0360970669bc72ef73285ee530dbb8ddc4457ddd73ad91f4939731b75c"},
+ {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "36ff18685872be8adfbf02d05676990bf9097c0fcc9695d3cae2f905ec59665c"},
{"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"},
{"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"},
{"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"},
diff --git a/internal/registry/remote.go b/internal/registry/remote.go
index 04a5c6b1d..46f507315 100644
--- a/internal/registry/remote.go
+++ b/internal/registry/remote.go
@@ -66,6 +66,7 @@ func OpenRemote(path string, input io.WriteCloser, output io.ReadCloser, wait fu
// OpenRemoteReconnect reads service-controlled answers, not an authority boundary.
// Failed or cancelled mutations are never retried; the next call reconnects.
+// Canceled does not mean not applied: cancellation can race a committed mutation.
func OpenRemoteReconnect(path string, connect func() (io.WriteCloser, io.ReadCloser, func() error, error)) (*Registry, error) {
c := &remoteClient{connect: connect}
if err := c.reconnect(context.Background()); err != nil {
diff --git a/internal/safefile/reap_linux.go b/internal/safefile/reap_linux.go
new file mode 100644
index 000000000..01bf03fbe
--- /dev/null
+++ b/internal/safefile/reap_linux.go
@@ -0,0 +1,177 @@
+package safefile
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// Reap removes abandoned Linux writer staging directories below dir. Traversal
+// stays on the opened filesystem and never follows symlinks. Only private
+// directories owned by this euid, older than an hour, and containing at most
+// the writer's single regular data file are eligible. Missing roots are harmless.
+func Reap(dir string) error {
+ fd, err := openDir(dir, false, 0)
+ if errors.Is(err, unix.ENOENT) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ err = reapTree(os.NewFile(uintptr(fd), dir), time.Now().Add(-time.Hour), 0)
+ if err != nil {
+ return &os.PathError{Op: "reap staging", Path: dir, Err: err}
+ }
+ return nil
+}
+
+func reapStageName(name string) bool {
+ const prefix = ".carlos-write-"
+ if !strings.HasPrefix(name, prefix) || len(name) != len(prefix)+32 {
+ return false
+ }
+ for _, c := range name[len(prefix):] {
+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+func reapOpenAt(dir int, name string, flags uint64) (int, error) {
+ return unix.Openat2(dir, name, &unix.OpenHow{
+ Flags: flags | unix.O_CLOEXEC | unix.O_NOFOLLOW,
+ Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS |
+ unix.RESOLVE_NO_XDEV,
+ })
+}
+
+func reapSkip(err error) bool {
+ return errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ELOOP) ||
+ errors.Is(err, unix.EXDEV) || errors.Is(err, unix.ENOTDIR)
+}
+
+func reapTree(dir *os.File, cutoff time.Time, depth int) error {
+ remaining := 100000
+ return reapTreeBudget(dir, cutoff, depth, &remaining)
+}
+
+func reapTreeBudget(dir *os.File, cutoff time.Time, depth int, remaining *int) error {
+ defer dir.Close()
+ if depth > 128 {
+ return fmt.Errorf("staging traversal exceeds 128 directories")
+ }
+ var failures error
+ for {
+ entries, err := dir.ReadDir(128)
+ if err != nil && err != io.EOF {
+ return errors.Join(failures, err)
+ }
+ for _, entry := range entries {
+ if *remaining <= 0 {
+ return errors.Join(failures, fmt.Errorf("staging traversal exceeds 100000 entries"))
+ }
+ *remaining -= 1
+ // Type is only a fast rejection; openat2 validates the live entry.
+ if entry.Type() != 0 && !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if strings.HasPrefix(name, ".carlos-write-") && !reapStageName(name) {
+ continue
+ }
+ fd, e := reapOpenAt(int(dir.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY)
+ if reapSkip(e) {
+ continue
+ }
+ if e != nil {
+ failures = errors.Join(failures, e)
+ continue
+ }
+ child := os.NewFile(uintptr(fd), name)
+ if reapStageName(name) {
+ e = reapStage(int(dir.Fd()), name, child, cutoff)
+ child.Close()
+ } else {
+ e = reapTreeBudget(child, cutoff, depth+1, remaining)
+ }
+ if e != nil {
+ failures = errors.Join(failures, e)
+ if *remaining <= 0 {
+ return failures
+ }
+ }
+ }
+ if err == io.EOF {
+ return failures
+ }
+ }
+}
+
+func reapOlder(st *unix.Stat_t, cutoff time.Time) bool {
+ return time.Unix(st.Mtim.Sec, st.Mtim.Nsec).Before(cutoff)
+}
+
+func reapSameEntry(parent int, name string, pinned *unix.Stat_t) bool {
+ var current unix.Stat_t
+ return unix.Fstatat(parent, name, &current, unix.AT_SYMLINK_NOFOLLOW) == nil &&
+ current.Dev == pinned.Dev && current.Ino == pinned.Ino &&
+ current.Mode == pinned.Mode && current.Uid == pinned.Uid &&
+ current.Nlink == pinned.Nlink
+}
+
+func reapStage(parent int, name string, dir *os.File, cutoff time.Time) error {
+ fd := int(dir.Fd())
+ var stage unix.Stat_t
+ if err := unix.Fstat(fd, &stage); err != nil {
+ return err
+ }
+ if stage.Uid != uint32(os.Geteuid()) || stage.Mode&0o777 != 0o700 ||
+ !reapOlder(&stage, cutoff) {
+ return nil
+ }
+ entries, err := dir.ReadDir(2)
+ if err != nil && err != io.EOF {
+ return err
+ }
+ if len(entries) > 1 || len(entries) == 1 && entries[0].Name() != "data" {
+ return nil
+ }
+ if len(entries) == 1 {
+ data, err := reapOpenAt(fd, "data", unix.O_PATH)
+ if reapSkip(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer unix.Close(data)
+ var st unix.Stat_t
+ if err := unix.Fstat(data, &st); err != nil {
+ return err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 ||
+ st.Uid != uint32(os.Geteuid()) || !reapOlder(&st, cutoff) ||
+ !reapSameEntry(parent, name, &stage) || !reapSameEntry(fd, "data", &st) {
+ return nil
+ }
+ if err := unix.Unlinkat(fd, "data", 0); err != nil && !errors.Is(err, unix.ENOENT) {
+ return err
+ }
+ }
+ // Unlinkat never follows a replacement symlink. Recheck the parent entry
+ // before removing the empty directory, keeping all content deletion anchored.
+ if !reapSameEntry(parent, name, &stage) {
+ return nil
+ }
+ err = unix.Unlinkat(parent, name, unix.AT_REMOVEDIR)
+ if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTEMPTY) || errors.Is(err, unix.ENOTDIR) {
+ return nil
+ }
+ return err
+}
diff --git a/internal/safefile/reap_linux_test.go b/internal/safefile/reap_linux_test.go
new file mode 100644
index 000000000..bd60238e3
--- /dev/null
+++ b/internal/safefile/reap_linux_test.go
@@ -0,0 +1,230 @@
+package safefile
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+func reapTestStage(t *testing.T, parent, name string, data bool) string {
+ t.Helper()
+ path := filepath.Join(parent, name)
+ if err := os.MkdirAll(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if data {
+ if err := os.WriteFile(filepath.Join(path, "data"), []byte("abandoned"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ reapTestAge(t, filepath.Join(path, "data"))
+ }
+ reapTestAge(t, path)
+ return path
+}
+
+func reapTestAge(t *testing.T, path string) {
+ t.Helper()
+ old := time.Now().Add(-2 * time.Hour)
+ if err := os.Chtimes(path, old, old); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestReapStaleWriterStages(t *testing.T) {
+ root := t.TempDir()
+ var removed, retained []string
+ for i, parent := range []string{root, filepath.Join(root, "versions", "v1"), filepath.Join(root, "staging")} {
+ removed = append(removed, reapTestStage(t, parent, fmt.Sprintf(".carlos-write-%032x", i+1), true))
+ }
+ removed = append(removed, reapTestStage(t, root, ".carlos-write-"+strings.Repeat("e", 32), false))
+ fresh := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("f", 32), true)
+ now := time.Now()
+ if err := os.Chtimes(fresh, now, now); err != nil {
+ t.Fatal(err)
+ }
+ retained = append(retained, fresh)
+ active := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("a", 32), true)
+ if err := os.Chtimes(filepath.Join(active, "data"), now, now); err != nil {
+ t.Fatal(err)
+ }
+ retained = append(retained, active)
+ for _, name := range []string{"ordinary", ".carlos-write-abc", ".carlos-write-" + strings.Repeat("A", 32), ".carlos-write-" + strings.Repeat("0", 33), ".carlos-write-" + strings.Repeat("g", 32)} {
+ retained = append(retained, reapTestStage(t, root, name, true))
+ }
+ if err := Reap(root); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range removed {
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Errorf("stale stage remains: %s: %v", path, err)
+ }
+ }
+ for _, path := range retained {
+ if _, err := os.Stat(filepath.Join(path, "data")); err != nil {
+ t.Errorf("retained stage changed: %s: %v", path, err)
+ }
+ }
+ if err := Reap(filepath.Join(root, "missing")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestReapRefusesUnsafeStages(t *testing.T) {
+ for _, kind := range []string{"stage-symlink", "data-symlink", "hardlink", "fifo", "nested", "extra", "writable", "foreign-owner"} {
+ t.Run(kind, func(t *testing.T) {
+ root := t.TempDir()
+ outside := t.TempDir()
+ victim := filepath.Join(outside, "victim")
+ if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ name := ".carlos-write-" + strings.Repeat("b", 32)
+ path := reapTestStage(t, root, name, false)
+ var err error
+ switch kind {
+ case "stage-symlink":
+ if err = os.Remove(path); err == nil {
+ err = os.Symlink(outside, path)
+ }
+ case "data-symlink":
+ err = os.Symlink(victim, filepath.Join(path, "data"))
+ case "hardlink":
+ err = os.Link(victim, filepath.Join(path, "data"))
+ case "fifo":
+ err = unix.Mkfifo(filepath.Join(path, "data"), 0o600)
+ case "nested":
+ reapTestStage(t, path, name, true)
+ case "extra":
+ err = os.WriteFile(filepath.Join(path, "unknown"), nil, 0o600)
+ case "writable":
+ err = os.Chmod(path, 0o777)
+ case "foreign-owner":
+ if os.Geteuid() != 0 {
+ t.Skip("requires root to make a foreign-owned stage")
+ }
+ err = os.Chown(path, 65534, 65534)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if kind != "stage-symlink" {
+ reapTestAge(t, path)
+ }
+ if err := Reap(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(path); err != nil {
+ t.Fatalf("unsafe stage removed: %v", err)
+ }
+ if kind == "nested" {
+ if _, err := os.Stat(filepath.Join(path, name, "data")); err != nil {
+ t.Fatalf("unexpected recursive deletion inside stage: %v", err)
+ }
+ }
+ if body, err := os.ReadFile(victim); err != nil || string(body) != "untouched" {
+ t.Fatalf("victim changed: %q %v", body, err)
+ }
+ })
+ }
+}
+
+func TestReapPinsRootAndRefusesSwappedStage(t *testing.T) {
+ root := t.TempDir()
+ parent := filepath.Join(root, "parent")
+ out := filepath.Join(root, "outside")
+ name := ".carlos-write-" + strings.Repeat("c", 32)
+ stage := reapTestStage(t, parent, name, true)
+ victim := reapTestStage(t, out, name, true)
+ dir, err := OpenDir(parent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dir.Close()
+ pinned, err := OpenDir(stage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pinned.Close()
+ if err := os.Rename(stage, stage+"-moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(victim, stage); err != nil {
+ t.Fatal(err)
+ }
+ if err := reapStage(int(dir.Fd()), name, pinned, time.Now().Add(-time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(stage+"-moved", "data")); err != nil {
+ t.Fatalf("replaced entry was not refused: %v", err)
+ }
+ if err := os.Rename(parent, parent+"-moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(out, parent); err != nil {
+ t.Fatal(err)
+ }
+ if err := reapTree(dir, time.Now().Add(-time.Hour), 0); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(victim, "data")); err != nil {
+ t.Fatalf("followed swapped parent: %v", err)
+ }
+ if err := Reap(parent); err == nil {
+ t.Fatal("accepted symlink root")
+ }
+}
+
+func TestReapRefusesBindMount(t *testing.T) {
+ root := t.TempDir()
+ out := t.TempDir()
+ name := ".carlos-write-" + strings.Repeat("d", 32)
+ victim := reapTestStage(t, out, name, true)
+ mount := filepath.Join(root, "mounted")
+ if err := os.Mkdir(mount, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount(out, mount, "", unix.MS_BIND, ""); err != nil {
+ if err == unix.EPERM || err == unix.EACCES {
+ t.Skip("bind mounts unavailable")
+ }
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := unix.Unmount(mount, 0); err != nil {
+ t.Errorf("unmount: %v", err)
+ }
+ }()
+ if err := Reap(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(victim, "data")); err != nil {
+ t.Fatalf("traversed bind mount: %v", err)
+ }
+}
+
+func TestReapContinuesPastUnreadableDirectory(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root bypasses directory permissions")
+ }
+ root := t.TempDir()
+ unreadable := filepath.Join(root, "unreadable")
+ if err := os.Mkdir(unreadable, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(unreadable, 0); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chmod(unreadable, 0o700)
+ stale := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("9", 32), true)
+ if err := Reap(root); err == nil {
+ t.Fatal("unreadable directory was not reported")
+ }
+ if _, err := os.Lstat(stale); !os.IsNotExist(err) {
+ t.Fatalf("unreadable sibling prevented cleanup: %v", err)
+ }
+}
diff --git a/internal/safefile/reap_other.go b/internal/safefile/reap_other.go
new file mode 100644
index 000000000..74f6e02f5
--- /dev/null
+++ b/internal/safefile/reap_other.go
@@ -0,0 +1,6 @@
+//go:build !linux
+
+package safefile
+
+// Reap has no work on platforms whose writer uses ordinary temporary files.
+func Reap(dir string) error { return nil }