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>
22 files changed,
+959
−39
internal/host/agent.go+7 −0internal/host/bootstrap.go+9 −24internal/host/claimroleassembly_linux.go+1 −1internal/host/claimroleassembly_linux_test.go+53 −0internal/host/host_test.go+34 −10internal/host/offcloudcredentials_linux.go+95 −0internal/host/offcloudcredentials_linux_test.go+123 −0internal/host/offcloudcredentials_other.go+16 −0internal/host/registrychild_exec_linux.go+29 −0internal/host/registrychild_exec_linux_test.go+56 −0internal/host/registrychild_exec_other.go+8 −0internal/host/registrychild_unix.go+4 −1internal/host/stagingcleanup.go+32 −0internal/host/stagingcleanup_linux_test.go+74 −0internal/host/systemapply.go+1 −0internal/host/units/carlos-system-recover.service+1 −1internal/host/units/carlos-system-update.service+1 −1internal/provision/plan9_b8_census_test.go+1 −1internal/registry/remote.go+1 −0internal/safefile/reap_linux.go+177 −0internal/safefile/reap_linux_test.go+230 −0internal/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, ¤t, 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 } |