host, release, registry, instancectl: root's adopt, apply and bootstrap file effects anchored and link-refusing; registry child pinned to its own executable, isolated fds, per-call cancellation; instancectl via the child (#267 remainder)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 files changed,
+1599
−615
internal/host/adoptlock.go+3 −1internal/host/claimroleassembly_linux.go+11 −10internal/host/claimroleassembly_linux_test.go+30 −0internal/host/claimstate_linux.go+15 −10internal/host/claimstate_linux_test.go+42 −0internal/host/claimstop_registry_test.go+34 −0internal/host/claimstop_run.go+26 −3internal/host/movesfloor_linux.go+16 −51internal/host/movesfloor_linux_test.go+61 −0internal/host/proofroot.go+4 −3internal/host/registry_privilege_test.go+29 −6internal/host/registrychild_exec_linux.go+34 −0internal/host/registrychild_exec_linux_test.go+171 −0internal/host/registrychild_exec_other.go+18 −0internal/host/registrychild_other.go+4 −0internal/host/registrychild_unix.go+39 −10internal/host/registrychild_unix_test.go+11 −8internal/host/registryreadonly.go+28 −0internal/host/restart.go+5 −27internal/host/systemapply.go+2 −4internal/host/systemapply_test.go+7 −2internal/host/systemapply_unix.go+9 −22internal/host/systemapply_unix_test.go+41 −0internal/host/systemstate.go+7 −34internal/hostfloor/controllers_linux.go+5 −2internal/hostfloor/controllers_linux_test.go+35 −0internal/hostfloor/participation.go+6 −6internal/instancectl/claim.go+3 −1internal/instancectl/server.go+7 −3internal/instancectl/stagedexe.go+0 −44internal/instancectl/stagedexe_test.go+5 −61internal/registry/readonly.go+26 −0internal/registry/readonly_test.go+66 −0internal/registry/registry.go+2 −0internal/registry/remote.go+99 −20internal/registry/remote_test.go+76 −14internal/release/adopt.go+2 −1internal/release/adopt_test.go+5 −5internal/release/bake.go+4 −25internal/release/bake_test.go+2 −2internal/release/conditional_delete.go+3 −2internal/release/fetch.go+14 −31internal/release/generations_publish_test.go+1 −1internal/release/healthy.go+5 −20internal/release/materializeheld.go+29 −10internal/release/materializeheld_test.go+37 −0internal/release/pins.go+7 −26internal/release/pins_test.go+2 −2internal/release/programhistory.go+5 −8internal/release/proofpolicy.go+3 −1internal/release/ratchet.go+3 −1internal/release/refusal.go+6 −20internal/release/stage_reuse_test.go+10 −5internal/release/stage_stream_test.go+2 −2internal/release/state_security_test.go+60 −0internal/release/store.go+27 −35internal/release/versions.go+18 −76internal/safefile/file.go+19 −0internal/safefile/file_linux.go+195 −0internal/safefile/file_linux_test.go+102 −0internal/safefile/file_other.go+61 −0
CI failed — run details
diff --git a/internal/host/adoptlock.go b/internal/host/adoptlock.go| index 70bfa6599..cc319123a 100644 |
| --- a/internal/host/adoptlock.go |
| +++ b/internal/host/adoptlock.go |
| @@ -3,6 +3,8 @@ package host |
| import ( |
| "os" |
| "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // adoptPassLockFile is the lock every root adoption pass holds for its whole |
| @@ -51,7 +53,7 @@ func lockAdoptPass(dd string) (unlock func(), err error) { |
| // the two locks cannot drift in how they are taken; the ORDER between them is |
| // fixed by their callers, adopt-pass first, and there is no third holder. |
| func lockExclusive(path string) (unlock func(), err error) { |
| - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) |
| + f, err := safefile.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/host/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go| index 2a2e6c6e4..0663c0817 100644 |
| --- a/internal/host/claimroleassembly_linux.go |
| +++ b/internal/host/claimroleassembly_linux.go |
| @@ -22,6 +22,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/safefile" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -143,16 +144,16 @@ func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, wo |
| files := []hostbox.LaunchFile{} |
| for _, suffix := range []string{"", "-wal", "-shm"} { |
| path := in.DBPath + suffix |
| - fd, e := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) |
| + f, e := safefile.OpenFile(path, os.O_RDONLY, 0) |
| if errors.Is(e, os.ErrNotExist) && suffix != "" { |
| continue |
| } |
| if errors.Is(e, os.ErrNotExist) && suffix == "" && in.Empty { |
| - fd, e = unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0600) |
| + f, e = safefile.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) |
| if e == nil { |
| - e = unix.Fsync(fd) |
| + e = f.Sync() |
| if e != nil { |
| - unix.Close(fd) |
| + f.Close() |
| } |
| } |
| } |
| @@ -160,12 +161,12 @@ func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, wo |
| return e |
| } |
| var stat unix.Stat_t |
| - e = unix.Fstat(fd, &stat) |
| - closeErr := unix.Close(fd) |
| + e = unix.Fstat(int(f.Fd()), &stat) |
| + closeErr := f.Close() |
| if e != nil || closeErr != nil { |
| return errors.Join(e, closeErr) |
| } |
| - if stat.Mode&unix.S_IFMT != unix.S_IFREG { |
| + if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Nlink != 1 { |
| return hostbox.ErrChanged |
| } |
| files = append(files, hostbox.LaunchFile{Device: uint64(stat.Dev), Inode: stat.Ino}) |
| @@ -195,17 +196,17 @@ func verifiedClaimExecutable(ctx context.Context, cmd *exec.Cmd, path, digest st |
| if err != nil || len(expected) != sha256.Size { |
| return nil, hostwrite.ErrUnavailable |
| } |
| - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) |
| + f, err := safefile.OpenFile(path, os.O_RDONLY, 0) |
| if err != nil { |
| return nil, err |
| } |
| - f := os.NewFile(uintptr(fd), path) |
| + fd := int(f.Fd()) |
| refuse := func(e error) (*os.File, error) { f.Close(); return nil, e } |
| var before unix.Stat_t |
| if err = unix.Fstat(fd, &before); err != nil { |
| return refuse(err) |
| } |
| - if before.Mode&unix.S_IFMT != unix.S_IFREG || before.Mode&0111 == 0 || before.Mode&0022 != 0 { |
| + if 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 9ebaebd3e..3c8d5b2fa 100644 |
| --- a/internal/host/claimroleassembly_linux_test.go |
| +++ b/internal/host/claimroleassembly_linux_test.go |
| @@ -67,3 +67,33 @@ func TestClaimExecutableRefusesSymlinkAndWritableArtifact(t *testing.T) { |
| t.Fatal("symlink admitted") |
| } |
| } |
| + |
| +func TestClaimExecutableRefusesHardLinkAndSymlinkParent(t *testing.T) { |
| + for _, kind := range []string{"hardlink", "parent"} { |
| + t.Run(kind, func(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "binary") |
| + body := []byte("approved executable bytes") |
| + if err := os.WriteFile(path, body, 0555); err != nil { |
| + t.Fatal(err) |
| + } |
| + unsafe := filepath.Join(dir, "unsafe") |
| + if kind == "hardlink" { |
| + if err := os.Link(path, unsafe); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + if err := os.Symlink(dir, unsafe); err != nil { |
| + t.Fatal(err) |
| + } |
| + unsafe = filepath.Join(unsafe, "binary") |
| + } |
| + digest := sha256.Sum256(body) |
| + f, err := verifiedClaimExecutable(t.Context(), exec.Command(unsafe), unsafe, hex.EncodeToString(digest[:])) |
| + if err == nil { |
| + f.Close() |
| + t.Fatal("unsafe executable accepted") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/claimstate_linux.go b/internal/host/claimstate_linux.go| index 97d8d940c..66473afcf 100644 |
| --- a/internal/host/claimstate_linux.go |
| +++ b/internal/host/claimstate_linux.go |
| @@ -9,6 +9,7 @@ import ( |
| "strconv" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/safefile" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -25,15 +26,18 @@ func claimStateOwner() (int, int, error) { |
| return uid, gid, err |
| } |
| func claimPrivatePathOwner(path string, uid, gid int, directory bool) error { |
| - flags := unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NOFOLLOW |
| + var f *os.File |
| + var err error |
| if directory { |
| - flags |= unix.O_DIRECTORY |
| + f, err = safefile.OpenDir(path) |
| + } else { |
| + f, err = safefile.OpenFile(path, os.O_RDONLY, 0) |
| } |
| - fd, err := unix.Open(path, flags, 0) |
| if err != nil { |
| return err |
| } |
| - defer unix.Close(fd) |
| + defer f.Close() |
| + fd := int(f.Fd()) |
| var stat unix.Stat_t |
| if err = unix.Fstat(fd, &stat); err != nil { |
| return err |
| @@ -44,7 +48,7 @@ func claimPrivatePathOwner(path string, uid, gid int, directory bool) error { |
| kind = unix.S_IFDIR |
| mode = 0700 |
| } |
| - if stat.Mode&unix.S_IFMT != kind { |
| + if stat.Mode&unix.S_IFMT != kind || (!directory && stat.Nlink != 1) { |
| return hostwrite.ErrUnavailable |
| } |
| if os.Geteuid() == 0 { |
| @@ -69,7 +73,7 @@ func prepareClaimStateDirectory(path string) error { |
| if err != nil { |
| return err |
| } |
| - if err = os.Mkdir(path, 0700); err != nil && !errors.Is(err, os.ErrExist) { |
| + if err = safefile.MkdirAll(path, 0700); err != nil { |
| return err |
| } |
| // Retirement journals are root's own and live in /var/lib/carlos-claims. |
| @@ -102,25 +106,26 @@ func prepareClaimStateDatabase(path string) error { |
| func prepareClaimDatabaseOwner(path string, uid, gid int) error { |
| for _, suffix := range []string{"", "-wal", "-shm"} { |
| name := path + suffix |
| - fd, err := unix.Open(name, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) |
| + f, err := safefile.OpenFile(name, os.O_RDWR, 0) |
| created := false |
| if errors.Is(err, unix.ENOENT) { |
| if suffix != "" { |
| continue |
| } |
| - fd, err = unix.Open(name, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_CREAT|unix.O_EXCL, 0600) |
| + f, err = safefile.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) |
| created = err == nil |
| } |
| if err != nil { |
| return err |
| } |
| err = func() error { |
| - defer unix.Close(fd) |
| + defer f.Close() |
| + fd := int(f.Fd()) |
| var stat unix.Stat_t |
| if err := unix.Fstat(fd, &stat); err != nil { |
| return err |
| } |
| - if stat.Mode&unix.S_IFMT != unix.S_IFREG || (!created && int(stat.Uid) != uid) { |
| + if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Nlink != 1 || (!created && int(stat.Uid) != uid) { |
| return hostwrite.ErrUnavailable |
| } |
| if created && os.Geteuid() == 0 { |
diff --git a/internal/host/claimstate_linux_test.go b/internal/host/claimstate_linux_test.go| index 0aceeb6df..c09e2a31a 100644 |
| --- a/internal/host/claimstate_linux_test.go |
| +++ b/internal/host/claimstate_linux_test.go |
| @@ -69,3 +69,45 @@ func TestClaimReviewDatabaseOwnerCheckedBeforeSQLite(t *testing.T) { |
| t.Fatalf("touched victim: %v", err) |
| } |
| } |
| + |
| +func TestClaimStateOwnersRejectHardLinksAndSymlinkParents(t *testing.T) { |
| + for _, kind := range []string{"hardlink", "parent"} { |
| + for _, operation := range []string{"repair", "prepare"} { |
| + t.Run(kind+"/"+operation, func(t *testing.T) { |
| + dir := t.TempDir() |
| + victim := filepath.Join(dir, "victim") |
| + if err := os.WriteFile(victim, []byte("unchanged"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "unsafe") |
| + if kind == "hardlink" { |
| + if err := os.Link(victim, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + if err := os.Symlink(dir, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + path = filepath.Join(path, "victim") |
| + } |
| + var err error |
| + if operation == "repair" { |
| + err = claimPrivatePathOwner(path, os.Getuid(), os.Getgid(), false) |
| + } else { |
| + err = prepareClaimDatabaseOwner(path, os.Getuid(), os.Getgid()) |
| + } |
| + if err == nil { |
| + t.Fatal("unsafe claim path accepted") |
| + } |
| + info, err := os.Stat(victim) |
| + if err != nil || info.Mode().Perm() != 0644 { |
| + t.Fatalf("victim mode changed: %v %v", info, err) |
| + } |
| + body, err := os.ReadFile(victim) |
| + if err != nil || string(body) != "unchanged" { |
| + t.Fatalf("victim changed: %q %v", body, err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/host/claimstop_registry_test.go b/internal/host/claimstop_registry_test.go| new file mode 100644 |
| index 000000000..753e818c3 |
| --- /dev/null |
| +++ b/internal/host/claimstop_registry_test.go |
| @@ -0,0 +1,34 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestInstanceRegistryHostAppReusesClaimRegistry(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + if err := reg.Upsert(registry.Route{Host: "blog.example.com", Kind: "instance", Account: "k7xq", App: "blog"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + lookup := instanceRegistryHostApp(reg) |
| + for i := 0; i < 2; i++ { |
| + acct, app, err := lookup("blog.example.com") |
| + if err != nil || acct != "k7xq" || app != "blog" { |
| + t.Fatalf("lookup = %q %q %v", acct, app, err) |
| + } |
| + } |
| + if _, _, err := lookup("unknown.example.com"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("unknown host = %v", err) |
| + } |
| + if _, err := reg.List(); err != nil { |
| + t.Fatalf("lookup closed shared registry: %v", err) |
| + } |
| +} |
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go| index dd8051168..02d0e8139 100644 |
| --- a/internal/host/claimstop_run.go |
| +++ b/internal/host/claimstop_run.go |
| @@ -2,7 +2,6 @@ package host |
| import ( |
| "context" |
| - "github.com/carlosframework/platform/internal/release" |
| "log" |
| "os" |
| "path/filepath" |
| @@ -10,6 +9,8 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| func RunInstanceControl(args []string) { |
| @@ -20,7 +21,7 @@ func RunInstanceControl(args []string) { |
| log.Fatal(err) |
| } |
| if !hostwrite.Enabled(claimEntryEnv(ctx)) { |
| - instancectl.Run(args) |
| + instancectl.RunWithRegistry(args, instanceRegistryHostApp(nil)) |
| return |
| } |
| reg, err := openServiceRegistry(filepath.Join(dataDir(), "registry.db")) |
| @@ -58,5 +59,27 @@ func RunInstanceControl(args []string) { |
| fenceDone := make(chan struct{}) |
| go func() { defer close(fenceDone); runHostFencePass(ctx) }() |
| defer func() { cancel(); <-fenceDone }() |
| - instancectl.RunWithClaim(args, handler) |
| + instancectl.RunWithClaim(args, handler, instanceRegistryHostApp(reg)) |
| +} |
| + |
| +func instanceRegistryHostApp(shared *registry.Registry) func(string) (string, string, error) { |
| + return func(host string) (string, string, error) { |
| + reg := shared |
| + if reg == nil { |
| + var err error |
| + reg, err = openServiceRegistryReadOnly(filepath.Join(dataDir(), "registry.db")) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + defer reg.Close() |
| + } |
| + row, err := reg.Get(host) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if row == nil { |
| + return "", "", os.ErrNotExist |
| + } |
| + return row.Account, row.App, nil |
| + } |
| } |
diff --git a/internal/host/movesfloor_linux.go b/internal/host/movesfloor_linux.go| index 8561740f0..88deb6e81 100644 |
| --- a/internal/host/movesfloor_linux.go |
| +++ b/internal/host/movesfloor_linux.go |
| @@ -3,8 +3,6 @@ |
| package host |
| import ( |
| - "crypto/rand" |
| - "encoding/hex" |
| "errors" |
| "fmt" |
| "os" |
| @@ -13,6 +11,8 @@ import ( |
| "strconv" |
| "syscall" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // movesDir is <DataDir>/.moves, opened with O_NOFOLLOW below a data dir |
| @@ -22,7 +22,7 @@ import ( |
| type movesDir struct { |
| fd int |
| path string |
| - lock int // the flocked floor.lock fd, or -1 |
| + lock *os.File |
| } |
| // floorLockFile serialises every writer of the floor files (root's apply, |
| @@ -47,11 +47,12 @@ const floorLockPoll = 100 * time.Millisecond |
| // The cost is one fsync of the data dir per write. |
| func openMovesDir(root string, create bool) (*movesDir, error) { |
| name := movesDirName |
| - rfd, err := syscall.Open(root, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0) |
| + rootDir, err := safefile.OpenDir(root) |
| if err != nil { |
| return nil, fmt.Errorf("open %s: %w", root, err) |
| } |
| - defer syscall.Close(rfd) |
| + defer rootDir.Close() |
| + rfd := int(rootDir.Fd()) |
| flags := syscall.O_RDONLY | syscall.O_DIRECTORY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC |
| fd, err := syscall.Openat(rfd, name, flags, 0) |
| if errors.Is(err, syscall.ENOENT) && create { |
| @@ -71,7 +72,7 @@ func openMovesDir(root string, create bool) (*movesDir, error) { |
| return nil, fmt.Errorf("fsync %s: %w", root, err) |
| } |
| } |
| - return &movesDir{fd: fd, path: filepath.Join(root, name), lock: -1}, nil |
| + return &movesDir{fd: fd, path: filepath.Join(root, name)}, nil |
| } |
| // syncParent fsyncs the data dir. A variable so a test can count the calls. |
| @@ -79,9 +80,9 @@ var syncParent = func(fd int) error { return syscall.Fsync(fd) } |
| // Close releases the lock (closing its fd) and the directory. |
| func (d *movesDir) Close() { |
| - if d.lock >= 0 { |
| - syscall.Close(d.lock) |
| - d.lock = -1 |
| + if d.lock != nil { |
| + d.lock.Close() |
| + d.lock = nil |
| } |
| syscall.Close(d.fd) |
| } |
| @@ -96,28 +97,24 @@ func (d *movesDir) Lock() error { return d.lockNamed(floorLockFile, floorLockWai |
| // or a FIFO planted in its place cannot redirect or park root. The fd is |
| // kept until Close, which releases the lock. One lock per movesDir. |
| func (d *movesDir) lockNamed(name string, wait time.Duration, busy error) error { |
| - fd, err := syscall.Openat(d.fd, name, syscall.O_RDONLY|syscall.O_CREAT|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0o644) |
| + f, err := safefile.OpenFileAt(d.fd, name, os.O_RDONLY|os.O_CREATE, 0o644) |
| if err != nil { |
| return fmt.Errorf("open %s: %w", filepath.Join(d.path, name), err) |
| } |
| - var st syscall.Stat_t |
| - if err := syscall.Fstat(fd, &st); err != nil || st.Mode&syscall.S_IFMT != syscall.S_IFREG { |
| - syscall.Close(fd) |
| - return fmt.Errorf("%s is not a regular file", filepath.Join(d.path, name)) |
| - } |
| + fd := int(f.Fd()) |
| deadline := time.Now().Add(wait) |
| for { |
| err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) |
| if err == nil { |
| - d.lock = fd |
| + d.lock = f |
| return nil |
| } |
| if !errors.Is(err, syscall.EWOULDBLOCK) { |
| - syscall.Close(fd) |
| + f.Close() |
| return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), err) |
| } |
| if time.Now().After(deadline) { |
| - syscall.Close(fd) |
| + f.Close() |
| return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), busy) |
| } |
| time.Sleep(floorLockPoll) |
| @@ -128,39 +125,7 @@ func (d *movesDir) lockNamed(name string, wait time.Duration, busy error) error |
| // temp file, fsync, rename, fsync of the directory), all relative to the |
| // directory's fd. The temp name is random, so two writers never share one. |
| func (d *movesDir) WriteDurable(name string, data []byte, mode os.FileMode) error { |
| - var suffix [6]byte |
| - if _, err := rand.Read(suffix[:]); err != nil { |
| - return err |
| - } |
| - tmp := name + ".tmp." + hex.EncodeToString(suffix[:]) |
| - fd, err := syscall.Openat(d.fd, tmp, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(mode.Perm())) |
| - if err != nil { |
| - return fmt.Errorf("create %s: %w", filepath.Join(d.path, tmp), err) |
| - } |
| - f := os.NewFile(uintptr(fd), filepath.Join(d.path, tmp)) |
| - werr := func() error { |
| - if _, err := f.Write(data); err != nil { |
| - return err |
| - } |
| - if err := f.Chmod(mode.Perm()); err != nil { |
| - return err |
| - } |
| - return f.Sync() |
| - }() |
| - if cerr := f.Close(); werr == nil { |
| - werr = cerr |
| - } |
| - if werr == nil { |
| - werr = syscall.Renameat(d.fd, tmp, d.fd, name) |
| - } |
| - if werr != nil { |
| - syscall.Unlinkat(d.fd, tmp) |
| - return fmt.Errorf("write %s: %w", filepath.Join(d.path, name), werr) |
| - } |
| - if err := syscall.Fsync(d.fd); err != nil { |
| - return fmt.Errorf("fsync %s: %w", d.path, err) |
| - } |
| - return nil |
| + return safefile.WriteAt(d.fd, name, data, mode) |
| } |
| // fchownToService hands a directory root just created to the service user, |
diff --git a/internal/host/movesfloor_linux_test.go b/internal/host/movesfloor_linux_test.go| index e8f6a7b2e..abbbbe48f 100644 |
| --- a/internal/host/movesfloor_linux_test.go |
| +++ b/internal/host/movesfloor_linux_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "os" |
| "path/filepath" |
| "syscall" |
| "testing" |
| @@ -100,3 +101,63 @@ func TestActivationGuardLockOrder(t *testing.T) { |
| t.Fatalf("result = %+v", r) |
| } |
| } |
| + |
| +func TestMovesDirRefusesLinkedRootAndLock(t *testing.T) { |
| + root := t.TempDir() |
| + alias := filepath.Join(t.TempDir(), "data") |
| + if err := os.Symlink(root, alias); err != nil { |
| + t.Fatal(err) |
| + } |
| + if d, err := openMovesDir(alias, true); err == nil { |
| + d.Close() |
| + t.Fatal("opened symlinked data root") |
| + } |
| + d, err := openMovesDir(root, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + victim := filepath.Join(root, "victim") |
| + if err := os.WriteFile(victim, []byte("unchanged"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Link(victim, filepath.Join(root, movesDirName, floorLockFile)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := d.Lock(); err == nil { |
| + t.Fatal("locked a multiply linked file") |
| + } |
| + body, err := os.ReadFile(victim) |
| + if err != nil || string(body) != "unchanged" { |
| + t.Fatalf("victim = %q, %v", body, err) |
| + } |
| +} |
| + |
| +func TestMovesDirWritesStayWithLockedDirectory(t *testing.T) { |
| + root := t.TempDir() |
| + d, err := openMovesDir(root, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + if err := d.Lock(); err != nil { |
| + t.Fatal(err) |
| + } |
| + moved := filepath.Join(root, "old-moves") |
| + if err := os.Rename(filepath.Join(root, movesDirName), moved); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(filepath.Join(root, movesDirName), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := d.WriteDurable("floor.json", []byte("written"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := os.ReadFile(filepath.Join(moved, "floor.json")) |
| + if err != nil || string(body) != "written" { |
| + t.Fatalf("pinned directory = %q, %v", body, err) |
| + } |
| + if _, err := os.Stat(filepath.Join(root, movesDirName, "floor.json")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("write reached unlocked replacement: %v", err) |
| + } |
| +} |
diff --git a/internal/host/proofroot.go b/internal/host/proofroot.go| index 5f2c1fb1e..b45b408d6 100644 |
| --- a/internal/host/proofroot.go |
| +++ b/internal/host/proofroot.go |
| @@ -38,6 +38,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| type proofRoot struct { |
| @@ -216,11 +217,11 @@ func (r proofRoot) markRequired(app string) { |
| if _, err := os.Stat(m); err == nil { |
| return |
| } |
| - if err := os.MkdirAll(filepath.Dir(m), 0o755); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(m), 0o755); err != nil { |
| r.logf("proof: writing the requirement marker %s: %v", m, err) |
| return |
| } |
| - if err := os.WriteFile(m, nil, 0o644); err != nil { |
| + if err := safefile.WriteFile(m, nil, 0o644); err != nil { |
| r.logf("proof: writing the requirement marker %s: %v", m, err) |
| } |
| } |
| @@ -286,7 +287,7 @@ func readProvingKey(path string) (ed25519.PrivateKey, error) { |
| if perm := fi.Mode().Perm(); perm&0o077 != 0 { |
| return nil, fmt.Errorf("proving key %s is mode %04o; it must be readable by root only (0600)", path, perm) |
| } |
| - b, err := os.ReadFile(path) |
| + b, err := safefile.ReadFile(path) |
| if err != nil { |
| return nil, fmt.Errorf("proving key: %w", err) |
| } |
diff --git a/internal/host/registry_privilege_test.go b/internal/host/registry_privilege_test.go| index a32453747..5612bea4d 100644 |
| --- a/internal/host/registry_privilege_test.go |
| +++ b/internal/host/registry_privilege_test.go |
| @@ -94,10 +94,12 @@ func TestRootRegistryRefusesPlantedFilesBeforeSQLite(t *testing.T) { |
| } |
| func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) { |
| - for _, tc := range []struct{ file, function string }{ |
| - {"agent.go", "runAdoptWithEnsureSystemUnits"}, |
| - {"systemapply.go", "openSystemRegistry"}, |
| - {"claimstop_run.go", "RunInstanceControl"}, |
| + for _, tc := range []struct{ file, function, opener string }{ |
| + {"agent.go", "runAdoptWithEnsureSystemUnits", "openServiceRegistry"}, |
| + {"systemapply.go", "openSystemRegistry", "openServiceRegistry"}, |
| + {"claimstop_run.go", "RunInstanceControl", "openServiceRegistry"}, |
| + {"claimstop_run.go", "instanceRegistryHostApp", "openServiceRegistryReadOnly"}, |
| + {"claimjournal_migration.go", "RunSystemJournalMigrate", "openServiceRegistry"}, |
| } { |
| t.Run(tc.function, func(t *testing.T) { |
| f, err := parser.ParseFile(token.NewFileSet(), tc.file, nil, 0) |
| @@ -115,10 +117,10 @@ func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) { |
| if !ok { |
| return true |
| } |
| - if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "openServiceRegistry" { |
| + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == tc.opener { |
| found = true |
| } |
| - if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Open" { |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && (sel.Sel.Name == "Open" || sel.Sel.Name == "OpenReadOnly") { |
| if id, ok := sel.X.(*ast.Ident); ok && id.Name == "registry" { |
| t.Error("privileged caller directly opens SQLite") |
| } |
| @@ -132,3 +134,24 @@ func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestStagedExecutableRegistryLookupCannotOpenSQLite(t *testing.T) { |
| + entries, err := os.ReadDir("../instancectl") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, entry := range entries { |
| + if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { |
| + continue |
| + } |
| + f, err := parser.ParseFile(token.NewFileSet(), filepath.Join("../instancectl", entry.Name()), nil, parser.ImportsOnly) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, imp := range f.Imports { |
| + if imp.Path.Value == `"database/sql"` || strings.Contains(imp.Path.Value, "sqlite") { |
| + t.Errorf("instancectl/%s imports SQLite instead of receiving the service registry view", entry.Name()) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/host/registrychild_exec_linux.go b/internal/host/registrychild_exec_linux.go| new file mode 100644 |
| index 000000000..84e324f62 |
| --- /dev/null |
| +++ b/internal/host/registrychild_exec_linux.go |
| @@ -0,0 +1,34 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "strconv" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +var registryExecutable, registryExecutableError = os.Open("/proc/self/exe") |
| + |
| +func registryChildExec(path string, uid, gid int) (*exec.Cmd, error) { |
| + if registryExecutableError != nil { |
| + return nil, registryExecutableError |
| + } |
| + cmd := exec.Command("/proc/self/fd/3", registryChildCommand, path, strconv.Itoa(uid), strconv.Itoa(gid)) |
| + cmd.ExtraFiles = []*os.File{registryExecutable} |
| + return cmd, nil |
| +} |
| + |
| +func registryChildCloseExec() error { |
| + return unix.CloseRange(3, ^uint(0), unix.CLOSE_RANGE_CLOEXEC) |
| +} |
| + |
| +func closeRegistryExecutable() { |
| + // fd 3 is the parent's pinned executable, passed only for exec. |
| + unix.Close(3) |
| + if registryExecutable != nil { |
| + registryExecutable.Close() |
| + } |
| +} |
diff --git a/internal/host/registrychild_exec_linux_test.go b/internal/host/registrychild_exec_linux_test.go| new file mode 100644 |
| index 000000000..913f55656 |
| --- /dev/null |
| +++ b/internal/host/registrychild_exec_linux_test.go |
| @@ -0,0 +1,171 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "io" |
| + "os" |
| + "os/exec" |
| + "os/user" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestRegistryChildFDHelper(t *testing.T) { |
| + if len(os.Args) == 0 || os.Args[len(os.Args)-1] != "--fd-probe" { |
| + return |
| + } |
| + closeRegistryExecutable() |
| + entries, err := os.ReadDir("/proc/self/fd") |
| + if err != nil { |
| + os.Exit(20) |
| + } |
| + for _, entry := range entries { |
| + fd, err := strconv.Atoi(entry.Name()) |
| + if err != nil || fd <= 2 { |
| + continue |
| + } |
| + target, err := os.Readlink("/proc/self/fd/" + entry.Name()) |
| + if os.IsNotExist(err) { |
| + continue |
| + } |
| + if err != nil { |
| + os.Exit(21) |
| + } |
| + if target != "anon_inode:[eventpoll]" && target != "anon_inode:[eventfd]" { |
| + os.Exit(22) |
| + } |
| + } |
| + os.Exit(0) |
| +} |
| + |
| +func TestRegistryChildPinnedExecutableAndMinimalFDs(t *testing.T) { |
| + dir := t.TempDir() |
| + original, err := os.Open("/proc/self/exe") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer original.Close() |
| + path := filepath.Join(dir, "worker") |
| + destination, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := io.Copy(destination, original); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := destination.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + pinned, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer pinned.Close() |
| + saved, savedErr := registryExecutable, registryExecutableError |
| + registryExecutable, registryExecutableError = pinned, nil |
| + defer func() { registryExecutable, registryExecutableError = saved, savedErr }() |
| + if err := os.Rename(path, path+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("replacement is not executable"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + inherited, err := os.OpenFile(filepath.Join(dir, "must-not-inherit"), os.O_CREATE|os.O_RDWR, 0600) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer inherited.Close() |
| + if _, err := unix.FcntlInt(inherited.Fd(), unix.F_SETFD, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd, err := registryChildExec("unused", 65534, 65534) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd.Args = []string{cmd.Path, "-test.run=^TestRegistryChildFDHelper$", "--", "--fd-probe"} |
| + if err := registryChildCloseExec(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("pinned child/fd probe: %v: %s", err, output) |
| + } |
| +} |
| + |
| +func TestRegistryChildRealMainStartup(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("requires root to exercise the real service boundary") |
| + } |
| + service, err := user.Lookup(serviceUser) |
| + if err != nil { |
| + t.Skip("requires the carlos service account") |
| + } |
| + uid, err := strconv.Atoi(service.Uid) |
| + if err != nil || uid <= 0 { |
| + t.Fatal("invalid service uid") |
| + } |
| + gid, err := strconv.Atoi(service.Gid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir, err := os.MkdirTemp(os.TempDir(), "registry-real-main-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(dir) |
| + if err := os.Chmod(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "carlos") |
| + build := exec.Command("go", "build", "-o", path, ".") |
| + build.Dir = "../.." |
| + if output, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build real main: %v: %s", err, output) |
| + } |
| + pinned, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer pinned.Close() |
| + saved, savedErr := registryExecutable, registryExecutableError |
| + registryExecutable, registryExecutableError = pinned, nil |
| + defer func() { registryExecutable, registryExecutableError = saved, savedErr }() |
| + if err := os.Rename(path, path+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("replacement"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + data := filepath.Join(dir, "data") |
| + if err := os.Mkdir(data, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(data, uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + dbPath := filepath.Join(data, "registry.db") |
| + t.Setenv("CARLOS_HOST_CLAIM", "true") |
| + r, err := openServiceRegistry(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + if err := r.Upsert(registry.Route{Host: "real-main", Kind: "instance"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + info, err := os.Stat(dbPath + suffix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + gotUID, gotGID, ok := fileOwner(info) |
| + if !ok || gotUID != uid || gotGID != gid { |
| + t.Fatalf("%s owner = %d:%d", strings.TrimPrefix(dbPath+suffix, dir), gotUID, gotGID) |
| + } |
| + } |
| +} |
diff --git a/internal/host/registrychild_exec_other.go b/internal/host/registrychild_exec_other.go| new file mode 100644 |
| index 000000000..1b1ec6472 |
| --- /dev/null |
| +++ b/internal/host/registrychild_exec_other.go |
| @@ -0,0 +1,18 @@ |
| +//go:build unix && !linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os/exec" |
| +) |
| + |
| +func registryChildExec(path string, uid, gid int) (*exec.Cmd, error) { |
| + return nil, errors.New("registry: privileged child requires Linux") |
| +} |
| + |
| +func registryChildCloseExec() error { |
| + return errors.New("registry: privileged child requires Linux") |
| +} |
| + |
| +func closeRegistryExecutable() {} |
diff --git a/internal/host/registrychild_other.go b/internal/host/registrychild_other.go| index 7b9215f72..2494f0478 100644 |
| --- a/internal/host/registrychild_other.go |
| +++ b/internal/host/registrychild_other.go |
| @@ -14,3 +14,7 @@ func openRegistryChild(string, int, int) (*registry.Registry, error) { |
| func RunRegistryChild([]string) error { |
| return errors.New("registry: privilege-dropped workers require unix") |
| } |
| + |
| +func openRegistryChildReadOnly(string, int, int) (*registry.Registry, error) { |
| + return nil, errors.New("registry: privilege-dropped workers require unix") |
| +} |
diff --git a/internal/host/registrychild_unix.go b/internal/host/registrychild_unix.go| index fd5fde016..ab98a09eb 100644 |
| --- a/internal/host/registrychild_unix.go |
| +++ b/internal/host/registrychild_unix.go |
| @@ -5,6 +5,7 @@ package host |
| import ( |
| "errors" |
| "fmt" |
| + "io" |
| "os" |
| "os/exec" |
| "path/filepath" |
| @@ -17,6 +18,14 @@ import ( |
| const registryChildCommand = "__registry-child" |
| func openRegistryChild(path string, uid, gid int) (*registry.Registry, error) { |
| + return openRegistryChildMode(path, uid, gid, false) |
| +} |
| + |
| +func openRegistryChildReadOnly(path string, uid, gid int) (*registry.Registry, error) { |
| + return openRegistryChildMode(path, uid, gid, true) |
| +} |
| + |
| +func openRegistryChildMode(path string, uid, gid int, readOnly bool) (*registry.Registry, error) { |
| if uid <= 0 || gid < 0 { |
| return nil, errors.New("registry: invalid service credentials") |
| } |
| @@ -24,17 +33,32 @@ func openRegistryChild(path string, uid, gid int) (*registry.Registry, error) { |
| if err != nil { |
| return nil, err |
| } |
| - executable, err := os.Executable() |
| + return registry.OpenRemoteReconnect(path, func() (io.WriteCloser, io.ReadCloser, func() error, error) { |
| + cmd, err := registryChildExec(path, uid, gid) |
| + if err != nil { |
| + return nil, nil, nil, err |
| + } |
| + if readOnly { |
| + cmd.Args = append(cmd.Args, "read-only") |
| + } |
| + return registryChildPipes(cmd, uid, gid) |
| + }) |
| +} |
| + |
| +func startRegistryChild(cmd *exec.Cmd, path string, uid, gid int) (*registry.Registry, error) { |
| + input, output, wait, err := registryChildPipes(cmd, uid, gid) |
| if err != nil { |
| return nil, err |
| } |
| - cmd := exec.Command(executable, registryChildCommand, path, strconv.Itoa(uid), strconv.Itoa(gid)) |
| - return startRegistryChild(cmd, path, uid, gid) |
| + return registry.OpenRemote(path, input, output, wait) |
| } |
| -func startRegistryChild(cmd *exec.Cmd, path string, uid, gid int) (*registry.Registry, error) { |
| +func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadCloser, func() error, error) { |
| if uid <= 0 || gid < 0 { |
| - return nil, errors.New("registry: invalid service credentials") |
| + return nil, nil, nil, errors.New("registry: invalid service credentials") |
| + } |
| + if err := registryChildCloseExec(); err != nil { |
| + return nil, nil, nil, err |
| } |
| cmd.Env = []string{} |
| if value, ok := os.LookupEnv("CARLOS_HOST_CLAIM"); ok { |
| @@ -45,24 +69,26 @@ func startRegistryChild(cmd *exec.Cmd, path string, uid, gid int) (*registry.Reg |
| cmd.Stderr = os.Stderr |
| input, err := cmd.StdinPipe() |
| if err != nil { |
| - return nil, err |
| + return nil, nil, nil, err |
| } |
| output, err := cmd.StdoutPipe() |
| if err != nil { |
| input.Close() |
| - return nil, err |
| + return nil, nil, nil, err |
| } |
| if err = cmd.Start(); err != nil { |
| input.Close() |
| output.Close() |
| - return nil, err |
| + return nil, nil, nil, err |
| } |
| - return registry.OpenRemote(path, input, output, cmd.Wait) |
| + return input, output, cmd.Wait, nil |
| } |
| // RunRegistryChild is the private re-exec entry point, before CLI dispatch. |
| func RunRegistryChild(args []string) error { |
| - if len(args) != 3 { |
| + closeRegistryExecutable() |
| + readOnly := len(args) == 4 && args[3] == "read-only" |
| + if len(args) != 3 && !readOnly { |
| return errors.New("registry: invalid child arguments") |
| } |
| uid, err := strconv.Atoi(args[1]) |
| @@ -80,5 +106,8 @@ func RunRegistryChild(args []string) error { |
| if uid <= 0 || os.Getuid() != uid || os.Geteuid() != uid || os.Getgid() != gid || os.Getegid() != gid || len(groups) != 0 { |
| return fmt.Errorf("registry: child credentials were not dropped") |
| } |
| + if readOnly { |
| + return registry.ServeRemoteReadOnly(args[0], os.Stdin, os.Stdout) |
| + } |
| return registry.ServeRemote(args[0], os.Stdin, os.Stdout) |
| } |
diff --git a/internal/host/registrychild_unix_test.go b/internal/host/registrychild_unix_test.go| index d6ba47cd1..295acfe36 100644 |
| --- a/internal/host/registrychild_unix_test.go |
| +++ b/internal/host/registrychild_unix_test.go |
| @@ -123,17 +123,20 @@ func TestRegistryChildDropsCredentials(t *testing.T) { |
| if err := r.UpsertTimer(timer); !errors.Is(err, registry.ErrClaimOwnership) { |
| t.Fatalf("worker accepted conflicting claim: %v", err) |
| } |
| - if err := r.Close(); err != nil { |
| - t.Fatal(err) |
| + for _, name := range []string{path, path + "-wal", path + "-shm"} { |
| + info, err := os.Stat(name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + gotUID, gotGID, ok := fileOwner(info) |
| + if !ok || gotUID != uid || gotGID != gid { |
| + t.Fatalf("%s owner = %d:%d, want %d:%d", name, gotUID, gotGID, uid, gid) |
| + } |
| } |
| - info, err := os.Stat(path) |
| - if err != nil { |
| + if err := r.Close(); err != nil { |
| t.Fatal(err) |
| } |
| - gotUID, gotGID, ok := fileOwner(info) |
| - if !ok || gotUID != uid || gotGID != gid { |
| - t.Fatalf("registry owner = %d:%d, want %d:%d", gotUID, gotGID, uid, gid) |
| - } |
| + |
| } |
| func TestRegistryChildRefusesRootCredentials(t *testing.T) { |
diff --git a/internal/host/registryreadonly.go b/internal/host/registryreadonly.go| new file mode 100644 |
| index 000000000..b844ea47d |
| --- /dev/null |
| +++ b/internal/host/registryreadonly.go |
| @@ -0,0 +1,28 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "os/user" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func openServiceRegistryReadOnly(path string) (*registry.Registry, error) { |
| + if os.Geteuid() != 0 { |
| + return registry.OpenReadOnly(path) |
| + } |
| + service, err := user.Lookup(serviceUser) |
| + if err != nil { |
| + return nil, err |
| + } |
| + uid, err := strconv.Atoi(service.Uid) |
| + if err != nil { |
| + return nil, err |
| + } |
| + gid, err := strconv.Atoi(service.Gid) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return openRegistryChildReadOnly(path, uid, gid) |
| +} |
diff --git a/internal/host/restart.go b/internal/host/restart.go| index e811109b0..376c62b8b 100644 |
| --- a/internal/host/restart.go |
| +++ b/internal/host/restart.go |
| @@ -56,6 +56,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // restartEligible widens release.Adoptable BY NAME, for this feature alone: |
| @@ -366,7 +367,7 @@ const restartRequestMarker = "restart-requested" |
| // a request that cannot be written costs latency, not correctness, because |
| // carlos-adopt.timer's hourly pass runs the same comparison underneath it. |
| func requestRootRestart(dd string) error { |
| - return os.WriteFile(filepath.Join(dd, restartRequestMarker), |
| + return safefile.WriteFile(filepath.Join(dd, restartRequestMarker), |
| []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644) |
| } |
| @@ -488,7 +489,7 @@ func newRestartMarks() *restartMarks { |
| // goes missing. |
| func loadRestartMarks(path string) (marks *restartMarks, known bool) { |
| m := newRestartMarks() |
| - raw, err := os.ReadFile(path) |
| + raw, err := safefile.ReadFile(path) |
| if err != nil { |
| if !errors.Is(err, os.ErrNotExist) { |
| log.Printf("adopt: restart marks %s unreadable (%v) — re-baselining, no restarts this pass", path, err) |
| @@ -530,30 +531,7 @@ func (m *restartMarks) save(path string) error { |
| return err |
| } |
| raw = append(raw, '\n') |
| - f, err := os.CreateTemp(filepath.Dir(path), "restart-marks-*.json") |
| - if err != nil { |
| - return err |
| - } |
| - tmp := f.Name() |
| - _, writeErr := f.Write(raw) |
| - closeErr := f.Close() |
| - if writeErr != nil { |
| - os.Remove(tmp) |
| - return writeErr |
| - } |
| - if closeErr != nil { |
| - os.Remove(tmp) |
| - return closeErr |
| - } |
| - if err := os.Chmod(tmp, 0o644); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := os.Rename(tmp, path); err != nil { |
| - os.Remove(tmp) // don't leave a stray temp file behind |
| - return err |
| - } |
| - return nil |
| + return safefile.WriteFile(path, raw, 0o644) |
| } |
| // seedRestartMarks writes the baseline the ROOT pass compares against, ONCE, |
| @@ -629,7 +607,7 @@ func seedRestartMarks(path string, mem *restartMemory) (needRootPass bool, err e |
| return false, err |
| } |
| raw = append(raw, '\n') |
| - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) |
| + f, err := safefile.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) |
| if err != nil { |
| if errors.Is(err, os.ErrExist) { |
| // Lost the race to the root pass; its mark is the better one — but |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 1527f9383..73a8902e9 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -1855,8 +1855,7 @@ func (a *systemApplier) readRequest() (systemRequest, error) { |
| if err != nil { |
| return systemRequest{}, err |
| } |
| - // Regular-file only: a FIFO planted here is refused, having already been |
| - // opened non-blocking so the open itself could not park root. |
| + // openBeneath already checked the inode; bound the request size too. |
| if !st.Mode().IsRegular() || st.Size() > systemMaxRequest { |
| return systemRequest{}, fmt.Errorf("request: not a regular file under %d bytes", systemMaxRequest) |
| } |
| @@ -1941,8 +1940,7 @@ func (a *systemApplier) stageCandidate(ctx context.Context, p *release.SystemPoi |
| return "", fmt.Errorf("candidate: %w", err) |
| } |
| defer src.Close() |
| - // Regular-file only: a FIFO or device the sandboxed user planted at the |
| - // cache path is refused here, having already been opened non-blocking. |
| + // openBeneath checked the inode before opening it for I/O. |
| if st, err := src.Stat(); err != nil || !st.Mode().IsRegular() { |
| return "", errors.New("candidate: not a regular file") |
| } |
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go| index ddca8e505..c0ced543c 100644 |
| --- a/internal/host/systemapply_test.go |
| +++ b/internal/host/systemapply_test.go |
| @@ -1884,8 +1884,13 @@ func TestDecidePrevForgetsTheOldVerdictBeforeTheCopy(t *testing.T) { |
| os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0.30.0\n"), 0o755) |
| writePrevVersion(f.a.optDir, "v0.30.0") |
| writeFileDurable(filepath.Join(f.a.optDir, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644) |
| - // The copy's temp file cannot be created. |
| - os.Mkdir(systemPrevPath(f.a.optDir)+".tmp", 0o755) |
| + // Prevent publication without depending on the writer's temporary name. |
| + if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(systemPrevPath(f.a.optDir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| if err := f.a.Run(context.Background(), false); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/host/systemapply_unix.go b/internal/host/systemapply_unix.go| index 883104836..b1b98cce9 100644 |
| --- a/internal/host/systemapply_unix.go |
| +++ b/internal/host/systemapply_unix.go |
| @@ -7,6 +7,8 @@ import ( |
| "os" |
| "path/filepath" |
| "syscall" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // The apply's two kernel-level needs, on the platform it actually runs on: |
| @@ -18,31 +20,16 @@ import ( |
| // The apply is a root oneshot on a Linux box and nothing else, so every |
| // other OS gets systemapply_other.go's refusals and merely has to build. |
| -// openBeneath opens root/parts... one component at a time with |
| -// O_DIRECTORY|O_NOFOLLOW for every directory and O_NOFOLLOW for the leaf, |
| -// so no component under a user-owned tree can be a symlink. O_NONBLOCK is |
| -// on every open: it is a no-op for regular files and directories, and it is |
| -// what stops a FIFO planted at the leaf by the sandboxed user from parking |
| -// root inside open(2) forever while it holds the apply lock. The callers' |
| -// IsRegular() check then refuses the FIFO. |
| +// openBeneath refuses symlinks in root and its descendants. The leaf is |
| +// pinned and checked as a single-link regular file before opening it for |
| +// I/O, so a planted device or FIFO is never opened for reading. |
| func openBeneath(root string, parts ...string) (*os.File, error) { |
| - fd, err := syscall.Open(root, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) |
| - if err != nil { |
| - return nil, fmt.Errorf("open %s: %w", root, err) |
| - } |
| - for i, part := range parts { |
| - flags := syscall.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_NONBLOCK | syscall.O_CLOEXEC |
| - if i < len(parts)-1 { |
| - flags |= syscall.O_DIRECTORY |
| - } |
| - next, err := syscall.Openat(fd, part, flags, 0) |
| - syscall.Close(fd) |
| - if err != nil { |
| - return nil, fmt.Errorf("open %s: %w", filepath.Join(append([]string{root}, parts[:i+1]...)...), err) |
| + for _, part := range parts { |
| + if !filepath.IsLocal(part) { |
| + return nil, fmt.Errorf("open %s: path escapes directory", root) |
| } |
| - fd = next |
| } |
| - return os.NewFile(uintptr(fd), filepath.Join(append([]string{root}, parts...)...)), nil |
| + return safefile.OpenFile(filepath.Join(append([]string{root}, parts...)...), os.O_RDONLY, 0) |
| } |
| // flockExclusiveNonblock refuses when another apply holds the lock, so a |
diff --git a/internal/host/systemapply_unix_test.go b/internal/host/systemapply_unix_test.go| index 0019cf089..350ff06b4 100644 |
| --- a/internal/host/systemapply_unix_test.go |
| +++ b/internal/host/systemapply_unix_test.go |
| @@ -5,12 +5,53 @@ package host |
| import ( |
| "context" |
| "os" |
| + "path/filepath" |
| "strings" |
| "syscall" |
| "testing" |
| "time" |
| ) |
| +func TestApplyInputRefusesHardLinksAndSymlinkedRoot(t *testing.T) { |
| + for _, kind := range []string{"request-hardlink", "artifact-hardlink", "root-symlink"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + f.promote("v2", []byte("#!/bin/sh\necho carlos v2\n"), false) |
| + p, err := f.a.verifyPointer(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := f.a.readRequest() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch kind { |
| + case "request-hardlink": |
| + err = os.Link(systemRequestPath(f.a.dataDir), filepath.Join(f.a.dataDir, "request-link")) |
| + case "artifact-hardlink": |
| + err = os.Link(systemCachePath(f.a.dataDir, "v2"), filepath.Join(f.a.dataDir, "artifact-link")) |
| + case "root-symlink": |
| + alias := filepath.Join(t.TempDir(), "data") |
| + err = os.Symlink(f.a.dataDir, alias) |
| + f.a.dataDir = alias |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if kind != "artifact-hardlink" { |
| + if _, err := f.a.readRequest(); err == nil { |
| + t.Fatal("unsafe request reached the parser") |
| + } |
| + } |
| + if kind != "request-hardlink" { |
| + if _, err := f.a.stageCandidate(context.Background(), p, req); err == nil { |
| + t.Fatal("unsafe artifact reached staging") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| // The sandboxed agent owns /data. A FIFO planted where root expects a |
| // regular file would park root inside open(2) — while it holds the apply |
| // lock, wedging every future apply on the box. Both reads under /data must |
diff --git a/internal/host/systemstate.go b/internal/host/systemstate.go| index 4b565ed78..a9d59381a 100644 |
| --- a/internal/host/systemstate.go |
| +++ b/internal/host/systemstate.go |
| @@ -8,6 +8,8 @@ import ( |
| "path/filepath" |
| "regexp" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // The on-box state of the platform self-update (spec §4, §6). Two homes: |
| @@ -140,40 +142,11 @@ func systemRootDir(optDir string) string { return filepath.Join(optDir, "syst |
| func systemPrevPath(optDir string) string { return filepath.Join(optDir, "carlos.prev") } |
| func systemBinaryPath(optDir string) string { return filepath.Join(optDir, "carlos") } |
| -// writeDurable is the one durable-write protocol on this box: fn fills |
| -// <path>.tmp, which is fsynced, renamed over path, and the directory |
| -// fsynced. A crash leaves the old file or the new one, never a torn one, |
| -// and the rename itself is on disk before the caller's next step depends |
| -// on it. A failure anywhere leaves the temp file behind no more than the |
| -// target: it is removed. |
| +// writeDurable publishes a file only after its contents are synced, then |
| +// syncs the parent directory. The shared writer pins directory descriptors |
| +// and keeps the temporary file in a private directory, including under /data. |
| func writeDurable(path string, mode os.FileMode, fn func(io.Writer) error) (err error) { |
| - tmp := path + ".tmp" |
| - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) |
| - if err != nil { |
| - return err |
| - } |
| - defer func() { |
| - if err != nil { |
| - f.Close() |
| - os.Remove(tmp) |
| - } |
| - }() |
| - if err = fn(f); err != nil { |
| - return err |
| - } |
| - if err = f.Chmod(mode); err != nil { |
| - return err |
| - } |
| - if err = f.Sync(); err != nil { |
| - return err |
| - } |
| - if err = f.Close(); err != nil { |
| - return err |
| - } |
| - if err = os.Rename(tmp, path); err != nil { |
| - return err |
| - } |
| - return syncDir(filepath.Dir(path)) |
| + return safefile.Write(path, mode, fn) |
| } |
| func writeFileDurable(path string, data []byte, mode os.FileMode) error { |
| @@ -206,7 +179,7 @@ func writeJSONDurable(path string, v any, mode os.FileMode) error { |
| // readJSONFile leaves os.ErrNotExist intact so callers can treat "no file" |
| // as a state, not a failure. |
| func readJSONFile(path string, v any) error { |
| - b, err := os.ReadFile(path) |
| + b, err := safefile.ReadFile(path) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/hostfloor/controllers_linux.go b/internal/hostfloor/controllers_linux.go| index ab3534543..a2ce2e8e5 100644 |
| --- a/internal/hostfloor/controllers_linux.go |
| +++ b/internal/hostfloor/controllers_linux.go |
| @@ -8,6 +8,8 @@ import ( |
| "path/filepath" |
| "golang.org/x/sys/unix" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // EnsureControllerDir creates <dataDir>/.hostexec, the claim controllers' |
| @@ -23,11 +25,12 @@ func EnsureControllerDir(dataDir string, uid, gid int) error { |
| if !filepath.IsAbs(dataDir) || filepath.Clean(dataDir) != dataDir { |
| return ErrFloor |
| } |
| - parent, err := unix.Open(dataDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + parentDir, err := safefile.OpenDir(dataDir) |
| if err != nil { |
| return fmt.Errorf("open %s: %w", dataDir, err) |
| } |
| - defer unix.Close(parent) |
| + defer parentDir.Close() |
| + parent := int(parentDir.Fd()) |
| if err = unix.Mkdirat(parent, ".hostexec", 0o700); err != nil && !errors.Is(err, unix.EEXIST) { |
| return fmt.Errorf("create %s/.hostexec: %w", dataDir, err) |
| } |
diff --git a/internal/hostfloor/controllers_linux_test.go b/internal/hostfloor/controllers_linux_test.go| new file mode 100644 |
| index 000000000..539bc7964 |
| --- /dev/null |
| +++ b/internal/hostfloor/controllers_linux_test.go |
| @@ -0,0 +1,35 @@ |
| +//go:build linux |
| + |
| +package hostfloor |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestControllerDirectoryRefusesLinkedAncestor(t *testing.T) { |
| + real := t.TempDir() |
| + if err := os.Mkdir(filepath.Join(real, "data"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + alias := filepath.Join(t.TempDir(), "linked") |
| + if err := os.Symlink(real, alias); err != nil { |
| + t.Fatal(err) |
| + } |
| + data := filepath.Join(alias, "data") |
| + if err := EnsureControllerDir(data, os.Getuid(), os.Getgid()); err == nil { |
| + t.Fatal("created controller directory through symlinked ancestor") |
| + } |
| + if _, err := os.Stat(filepath.Join(real, "data", ".hostexec")); !os.IsNotExist(err) { |
| + t.Fatalf("controller directory created: %v", err) |
| + } |
| + if err := os.Mkdir(filepath.Join(real, "data", ".hostexec"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, strict := range []bool{false, true} { |
| + if err := CheckParticipation(data, strict); err == nil { |
| + t.Fatalf("participation accepted linked ancestor with requireDirectory=%v", strict) |
| + } |
| + } |
| +} |
diff --git a/internal/hostfloor/participation.go b/internal/hostfloor/participation.go| index 4df1aa741..87a603294 100644 |
| --- a/internal/hostfloor/participation.go |
| +++ b/internal/hostfloor/participation.go |
| @@ -7,23 +7,23 @@ import ( |
| "path/filepath" |
| "sort" |
| "strings" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // CheckParticipation reads only directory entries. Floor-armed boxes require |
| // the controller directory; legacy region HA boxes may not have one yet. |
| func CheckParticipation(dataDir string, requireDirectory bool) error { |
| dir := filepath.Join(dataDir, ".hostexec") |
| - fi, err := os.Lstat(dir) |
| + f, err := safefile.OpenDir(dir) |
| if errors.Is(err, os.ErrNotExist) && !requireDirectory { |
| return nil |
| } |
| if err != nil { |
| - return fmt.Errorf("%s cannot be read: %v", dir, err) |
| - } |
| - if !fi.IsDir() { |
| - return fmt.Errorf("%s is not a directory", dir) |
| + return fmt.Errorf("%s cannot be listed: %v", dir, err) |
| } |
| - entries, err := os.ReadDir(dir) |
| + defer f.Close() |
| + entries, err := f.ReadDir(-1) |
| if err != nil { |
| return fmt.Errorf("%s cannot be listed: %v", dir, err) |
| } |
diff --git a/internal/instancectl/claim.go b/internal/instancectl/claim.go| index 25d46a36c..0513258c1 100644 |
| --- a/internal/instancectl/claim.go |
| +++ b/internal/instancectl/claim.go |
| @@ -15,4 +15,6 @@ func RequestConn(ctx context.Context) net.Conn { |
| func requestConnContext(ctx context.Context, c net.Conn) context.Context { |
| return context.WithValue(ctx, requestConnKey{}, c) |
| } |
| -func RunWithClaim(args []string, handler http.Handler) { runWithClaim(args, handler) } |
| +func RunWithClaim(args []string, handler http.Handler, hostApp func(string) (string, string, error)) { |
| + runWithClaim(args, handler, hostApp) |
| +} |
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go| index 4e799946c..e00e8cd5d 100644 |
| --- a/internal/instancectl/server.go |
| +++ b/internal/instancectl/server.go |
| @@ -127,9 +127,13 @@ const ( |
| ) |
| // Run is `carlos instance-control`. |
| -func Run(args []string) { runWithClaim(args, nil) } |
| +func Run(args []string) { runWithClaim(args, nil, nil) } |
| -func runWithClaim(args []string, claim http.Handler) { |
| +func RunWithRegistry(args []string, hostApp func(string) (string, string, error)) { |
| + runWithClaim(args, nil, hostApp) |
| +} |
| + |
| +func runWithClaim(args []string, claim http.Handler, hostApp func(string) (string, string, error)) { |
| fs := flag.NewFlagSet("instance-control", flag.ExitOnError) |
| fs.Usage = cliflag.Usage(fs, "usage: carlos instance-control (socket-activated by carlos-instance-control.socket)") |
| _ = fs.Parse(args) |
| @@ -154,7 +158,7 @@ func runWithClaim(args []string, claim http.Handler) { |
| Logf: log.Printf, |
| ExeRoot: filepath.Join(controlsock.Dir, "exe"), |
| OptDir: "/opt/carlos", |
| - HostApp: registryHostApp(filepath.Join(dataDir(), "registry.db")), |
| + HostApp: hostApp, |
| DropInDir: "/run/systemd/system", |
| ResolvConf: "/etc/resolv.conf", |
| ResolvedConf: ResolvedConf, |
diff --git a/internal/instancectl/stagedexe.go b/internal/instancectl/stagedexe.go| index 334e43f6f..34dbc66ad 100644 |
| --- a/internal/instancectl/stagedexe.go |
| +++ b/internal/instancectl/stagedexe.go |
| @@ -2,13 +2,11 @@ package instancectl |
| import ( |
| "crypto/sha256" |
| - "database/sql" |
| "encoding/hex" |
| "errors" |
| "fmt" |
| "io" |
| "net/http" |
| - "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -17,8 +15,6 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| - |
| - _ "modernc.org/sqlite" |
| ) |
| // StagedExe is a nested generation's executable as the agent names it: the |
| @@ -117,43 +113,3 @@ func (s *Server) ensureHostExeAt(host string, x StagedExe) error { |
| } |
| return ensureExe(s.ExeRoot, provision.InstanceID(host), x.Path, host) |
| } |
| - |
| -// dataDir is the agent's data directory: CARLOS_DATA_DIR, else /data (the |
| -// agent's own resolver in host/agent.go, repeated as instanceexec does). |
| -func dataDir() string { |
| - if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { |
| - return d |
| - } |
| - return "/data" |
| -} |
| - |
| -// registryHostApp reads a host's account and app from the agent's registry |
| -// at path, read-only. Root must never leave root-owned files beside the |
| -// agent's WAL database, so the read is only attempted while the agent has |
| -// the registry open, that is, while its -wal and -shm files exist. SQLite |
| -// would otherwise create them (mode=ro does not stop it making the wal-index |
| -// of a WAL database), owned by root and unusable by the agent. With either |
| -// missing the lookup is refused and the start with it. A refused lookup is |
| -// an abandoned swap, never a failed build. |
| -func registryHostApp(path string) func(host string) (string, string, error) { |
| - return func(host string) (string, string, error) { |
| - for _, p := range []string{path, path + "-wal", path + "-shm"} { |
| - fi, err := os.Lstat(p) |
| - if err != nil { |
| - return "", "", fmt.Errorf("registry %s is not open by the agent (%v)", path, err) |
| - } |
| - if !fi.Mode().IsRegular() { |
| - return "", "", fmt.Errorf("registry %s: %s is not a regular file", path, p) |
| - } |
| - } |
| - u := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro&_pragma=busy_timeout(5000)"} |
| - db, err := sql.Open("sqlite", u.String()) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - defer db.Close() |
| - var acct, app string |
| - err = db.QueryRow(`SELECT account, app FROM routes WHERE host = ?`, host).Scan(&acct, &app) |
| - return acct, app, err |
| - } |
| -} |
diff --git a/internal/instancectl/stagedexe_test.go b/internal/instancectl/stagedexe_test.go| index 16e6be9ca..180b53b8a 100644 |
| --- a/internal/instancectl/stagedexe_test.go |
| +++ b/internal/instancectl/stagedexe_test.go |
| @@ -2,7 +2,6 @@ package instancectl |
| import ( |
| "context" |
| - "database/sql" |
| "errors" |
| "os" |
| "path/filepath" |
| @@ -10,7 +9,6 @@ import ( |
| "testing" |
| "github.com/carlosframework/platform/internal/cgroup" |
| - "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -51,64 +49,10 @@ func TestGenStartRefusesAStagedExeRootCannotVouchFor(t *testing.T) { |
| } |
| } |
| -// Root reads the host's account and app from the registry read-only, and |
| -// only while the agent has it open, so it never leaves root-owned WAL files |
| -// the agent cannot use. |
| -func TestRegistryHostAppReadsTheRow(t *testing.T) { |
| - path := filepath.Join(t.TempDir(), "registry.db") |
| - // The agent's own handle stays open for the whole test, as it does in |
| - // production: that is what keeps the -wal and -shm files in place. |
| - reg, err := registry.Open(path) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - defer reg.Close() |
| - db, err := sql.Open("sqlite", path) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if _, err := db.Exec(`INSERT INTO routes (host, kind, account, app, created_at) VALUES ('blog.example.com', 'instance', 'k7xq', 'blog', 1)`); err != nil { |
| - t.Fatal(err) |
| - } |
| - db.Close() |
| - acct, app, err := registryHostApp(path)("blog.example.com") |
| - if err != nil || acct != "k7xq" || app != "blog" { |
| - t.Fatalf("registryHostApp = %q %q %v", acct, app, err) |
| - } |
| - if _, _, err := registryHostApp(path)("nobody.example.com"); err == nil { |
| - t.Fatal("an unknown host was answered") |
| - } |
| -} |
| - |
| -// With the agent's registry closed there are no -wal or -shm files, and |
| -// opening a WAL database would create them, owned by whoever opened it (root |
| -// here). The lookup refuses instead and leaves the directory as it found it. |
| -func TestRegistryHostAppCreatesNothingBesideTheDatabase(t *testing.T) { |
| - dir := t.TempDir() |
| - path := filepath.Join(dir, "registry.db") |
| - reg, err := registry.Open(path) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - reg.Close() |
| - before, err := os.ReadDir(dir) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if _, _, err := registryHostApp(path)("blog.example.com"); err == nil { |
| - t.Fatal("a registry the agent does not hold open was read") |
| - } |
| - after, err := os.ReadDir(dir) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if len(after) != len(before) { |
| - t.Fatalf("the lookup changed %s: %d entries before, %d after", dir, len(before), len(after)) |
| - } |
| - if _, _, err := registryHostApp(filepath.Join(dir, "nope.db"))("blog.example.com"); err == nil { |
| - t.Fatal("a missing registry was read") |
| - } |
| - if _, err := os.Stat(filepath.Join(dir, "nope.db")); !os.IsNotExist(err) { |
| - t.Fatal("the lookup created a registry file") |
| +func TestStagedExeWithoutRegistryViewFailsClosed(t *testing.T) { |
| + s := &Server{OptDir: t.TempDir()} |
| + err := s.validateStagedExe(genHost, StagedExe{Version: "v2", Digest: strings.Repeat("0", 64)}) |
| + if !errors.Is(err, ErrStagedExe) { |
| + t.Fatalf("validateStagedExe = %v, want ErrStagedExe", err) |
| } |
| } |
diff --git a/internal/registry/readonly.go b/internal/registry/readonly.go| new file mode 100644 |
| index 000000000..3f2f6add7 |
| --- /dev/null |
| +++ b/internal/registry/readonly.go |
| @@ -0,0 +1,26 @@ |
| +package registry |
| + |
| +import ( |
| + "database/sql" |
| + "net/url" |
| + "path/filepath" |
| + |
| + "modernc.org/sqlite" |
| +) |
| + |
| +func OpenReadOnly(path string) (*Registry, error) { |
| + absolute, err := filepath.Abs(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + uri := url.URL{Scheme: "file", Path: absolute} |
| + query := url.Values{"mode": {"ro"}, "_pragma": {"busy_timeout(5000)", "query_only(1)"}} |
| + uri.RawQuery = query.Encode() |
| + d := sql.OpenDB(registryConnector{d: &sqlite.Driver{}, path: uri.String()}) |
| + d.SetMaxOpenConns(1) |
| + if err := d.Ping(); err != nil { |
| + d.Close() |
| + return nil, err |
| + } |
| + return &Registry{sql: d, path: path}, nil |
| +} |
diff --git a/internal/registry/readonly_test.go b/internal/registry/readonly_test.go| new file mode 100644 |
| index 000000000..89ac7b0a6 |
| --- /dev/null |
| +++ b/internal/registry/readonly_test.go |
| @@ -0,0 +1,66 @@ |
| +package registry |
| + |
| +import ( |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestRemoteReadOnlyWALRegistry(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("read-only permission fixture requires a nonroot uid") |
| + } |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "registry.db") |
| + writer, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer writer.Close() |
| + if err := writer.Upsert(Route{Host: "readonly", Kind: "instance", Account: "acct", App: "app"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + if err := os.Chmod(path+suffix, 0444); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.Chmod(dir, 0555); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { |
| + os.Chmod(dir, 0755) |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + os.Chmod(path+suffix, 0644) |
| + } |
| + }() |
| + requestR, requestW := io.Pipe() |
| + responseR, responseW := io.Pipe() |
| + done := make(chan error, 1) |
| + go func() { |
| + err := ServeRemoteReadOnly(path, requestR, responseW) |
| + requestR.Close() |
| + responseW.Close() |
| + done <- err |
| + }() |
| + reader, err := OpenRemote(path, requestW, responseR, func() error { return <-done }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reader.Close() |
| + row, err := reader.Get("readonly") |
| + if err != nil || row == nil || row.Account != "acct" || row.App != "app" { |
| + t.Fatalf("readonly WAL read: %+v %v", row, err) |
| + } |
| + if err := reader.Upsert(Route{Host: "forbidden", Kind: "instance"}); err == nil { |
| + t.Fatal("read-only worker accepted mutation") |
| + } |
| + if _, err := reader.Get("readonly"); err != nil { |
| + t.Fatalf("read after refused mutation: %v", err) |
| + } |
| + if writable, err := Open(path); err == nil { |
| + writable.Close() |
| + t.Fatal("normal registry open accepted read-only state") |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index c46cfb364..8dc8512cc 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -477,10 +477,12 @@ func Open(path string) (*Registry, error) { |
| // Open the first connection now, so a pragma failure is Open's error as |
| // it was when the pragmas ran here. |
| if err := d.Ping(); err != nil { |
| + d.Close() |
| return nil, err |
| } |
| r := &Registry{sql: d, path: path} |
| if err := r.migrate(); err != nil { |
| + d.Close() |
| return nil, err |
| } |
| return r, nil |
diff --git a/internal/registry/remote.go b/internal/registry/remote.go| index 4a400c961..04a5c6b1d 100644 |
| --- a/internal/registry/remote.go |
| +++ b/internal/registry/remote.go |
| @@ -45,43 +45,89 @@ func setResponseError(out *remoteResponse, err error) { |
| } |
| type remoteClient struct { |
| - mu sync.Mutex |
| - enc *gob.Encoder |
| - dec *gob.Decoder |
| - input io.WriteCloser |
| - output io.ReadCloser |
| - wait func() error |
| - closed bool |
| + mu sync.Mutex |
| + enc *gob.Encoder |
| + dec *gob.Decoder |
| + input io.WriteCloser |
| + output io.ReadCloser |
| + wait func() error |
| + closed bool |
| + connect func() (io.WriteCloser, io.ReadCloser, func() error, error) |
| } |
| // OpenRemote connects to an already privilege-dropped registry worker. |
| func OpenRemote(path string, input io.WriteCloser, output io.ReadCloser, wait func() error) (*Registry, error) { |
| - c := &remoteClient{enc: gob.NewEncoder(input), dec: gob.NewDecoder(output), input: input, output: output, wait: wait} |
| + c := &remoteClient{} |
| + if err := c.attach(input, output, wait); err != nil { |
| + return nil, err |
| + } |
| + return &Registry{path: path, remote: c}, nil |
| +} |
| + |
| +// OpenRemoteReconnect reads service-controlled answers, not an authority boundary. |
| +// Failed or cancelled mutations are never retried; the next call reconnects. |
| +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 { |
| + return nil, err |
| + } |
| + return &Registry{path: path, remote: c}, nil |
| +} |
| + |
| +func (c *remoteClient) reconnect(ctx context.Context) (err error) { |
| + input, output, wait, err := c.connect() |
| + if err != nil { |
| + return err |
| + } |
| + finished := make(chan struct{}) |
| + stop := context.AfterFunc(ctx, func() { input.Close(); output.Close(); close(finished) }) |
| + defer func() { |
| + if !stop() { |
| + <-finished |
| + } |
| + if ctx.Err() != nil { |
| + c.disconnect() |
| + err = ctx.Err() |
| + } |
| + }() |
| + return c.attach(input, output, wait) |
| +} |
| + |
| +func (c *remoteClient) attach(input io.WriteCloser, output io.ReadCloser, wait func() error) error { |
| + c.input, c.output, c.wait = input, output, wait |
| + c.enc, c.dec = gob.NewEncoder(input), gob.NewDecoder(output) |
| var ready remoteResponse |
| err := c.dec.Decode(&ready) |
| if err == nil { |
| err = responseError(ready) |
| } |
| if err != nil { |
| - c.stop() |
| - return nil, err |
| + c.disconnect() |
| } |
| - return &Registry{path: path, remote: c}, nil |
| + return err |
| } |
| -func (c *remoteClient) stop() error { |
| - if c.closed { |
| +func (c *remoteClient) disconnect() error { |
| + if c.input == nil { |
| return nil |
| } |
| - c.closed = true |
| c.input.Close() |
| c.output.Close() |
| + c.input, c.output = nil, nil |
| if c.wait != nil { |
| return c.wait() |
| } |
| return nil |
| } |
| +func (c *remoteClient) stop() error { |
| + if c.closed { |
| + return nil |
| + } |
| + c.closed = true |
| + return c.disconnect() |
| +} |
| + |
| func (c *remoteClient) close() error { |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| @@ -102,19 +148,30 @@ func (c *remoteClient) call(ctx context.Context, q remoteRequest, callback func( |
| q.Deadline = deadline.UnixNano() |
| } |
| } |
| + if c.input == nil { |
| + if c.connect == nil { |
| + return out, os.ErrClosed |
| + } |
| + reconnectContext := ctx |
| + if reconnectContext == nil { |
| + reconnectContext = context.Background() |
| + } |
| + if err = c.reconnect(reconnectContext); err != nil { |
| + return |
| + } |
| + } |
| // Closing the pipe cancels the worker context, including an in-flight SQL call. |
| if ctx != nil { |
| + input, output := c.input, c.output |
| stopped := make(chan struct{}) |
| - stop := context.AfterFunc(ctx, func() { c.input.Close(); c.output.Close(); close(stopped) }) |
| + stop := context.AfterFunc(ctx, func() { input.Close(); output.Close(); close(stopped) }) |
| defer func() { |
| if !stop() { |
| <-stopped |
| } |
| - }() |
| - defer func() { |
| if ctx.Err() != nil { |
| err = ctx.Err() |
| - c.stop() |
| + c.disconnect() |
| } |
| }() |
| } |
| @@ -122,7 +179,7 @@ func (c *remoteClient) call(ctx context.Context, q remoteRequest, callback func( |
| complete := false |
| defer func() { |
| if !complete { |
| - c.stop() |
| + c.disconnect() |
| } |
| }() |
| if err = c.enc.Encode(q); err != nil { |
| @@ -165,11 +222,25 @@ func (c *remoteClient) call(ctx context.Context, q remoteRequest, callback func( |
| // ServeRemote owns SQLite in the worker. Callers must drop credentials first. |
| func ServeRemote(path string, input io.Reader, output io.Writer) error { |
| + return serveRemote(path, input, output, false) |
| +} |
| + |
| +func ServeRemoteReadOnly(path string, input io.Reader, output io.Writer) error { |
| + return serveRemote(path, input, output, true) |
| +} |
| + |
| +func serveRemote(path string, input io.Reader, output io.Writer, readOnly bool) error { |
| if os.Geteuid() == 0 { |
| return errors.New("registry: remote worker refuses root") |
| } |
| enc, dec := gob.NewEncoder(output), gob.NewDecoder(input) |
| - r, err := Open(path) |
| + var r *Registry |
| + var err error |
| + if readOnly { |
| + r, err = OpenReadOnly(path) |
| + } else { |
| + r, err = Open(path) |
| + } |
| ready := remoteResponse{} |
| setResponseError(&ready, err) |
| if writeErr := enc.Encode(ready); writeErr != nil { |
| @@ -203,6 +274,14 @@ func ServeRemote(path string, input io.Reader, output io.Writer) error { |
| } |
| }() |
| for q := range requests { |
| + if readOnly && q.Method != "Get" { |
| + out := remoteResponse{} |
| + setResponseError(&out, errRemoteMethod) |
| + if err := enc.Encode(out); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| ctx := workerCtx |
| cancel := func() {} |
| if q.Deadline != 0 { |
diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go| index e714d0eba..5d2e480cb 100644 |
| --- a/internal/registry/remote_test.go |
| +++ b/internal/registry/remote_test.go |
| @@ -16,16 +16,18 @@ func remoteTestRegistry(t *testing.T) (*Registry, string) { |
| t.Skip("in-process transport fixture must not open service files as root; credential tests use a dropped child") |
| } |
| path := filepath.Join(t.TempDir(), "registry.db") |
| - requestR, requestW := io.Pipe() |
| - responseR, responseW := io.Pipe() |
| - done := make(chan error, 1) |
| - go func() { |
| - err := ServeRemote(path, requestR, responseW) |
| - requestR.Close() |
| - responseW.Close() |
| - done <- err |
| - }() |
| - r, err := OpenRemote(path, requestW, responseR, func() error { return <-done }) |
| + r, err := OpenRemoteReconnect(path, func() (io.WriteCloser, io.ReadCloser, func() error, error) { |
| + requestR, requestW := io.Pipe() |
| + responseR, responseW := io.Pipe() |
| + done := make(chan error, 1) |
| + go func() { |
| + err := ServeRemote(path, requestR, responseW) |
| + requestR.Close() |
| + responseW.Close() |
| + done <- err |
| + }() |
| + return requestW, responseR, func() error { return <-done }, nil |
| + }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -100,9 +102,9 @@ func TestRemoteHoldsFrozenKeepsWriteLock(t *testing.T) { |
| } |
| func TestRemoteRetirementArchiveAndCancellation(t *testing.T) { |
| - for _, mode := range []string{"commit", "cancel", "empty-error"} { |
| + for _, mode := range []string{"commit", "cancel", "deadline", "empty-error"} { |
| t.Run(mode, func(t *testing.T) { |
| - cancelRequest := mode == "cancel" |
| + cancelRequest := mode == "cancel" || mode == "deadline" |
| callbackError := errors.New("") |
| r, path := remoteTestRegistry(t) |
| owner := ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| @@ -117,13 +119,19 @@ func TestRemoteRetirementArchiveAndCancellation(t *testing.T) { |
| t.Fatal(err) |
| } |
| ctx, cancel := context.WithCancel(t.Context()) |
| + if mode == "deadline" { |
| + cancel() |
| + ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond) |
| + } |
| defer cancel() |
| err = r.RetireClaimRows(ctx, "journal", []ClaimRow{{Kind: "route", Host: "claim", Ownership: owner}}, func(archives []ClaimRowArchive) error { |
| if len(archives) != 1 || archives[0].Row.Ownership != owner { |
| t.Fatalf("archive = %+v", archives) |
| } |
| if cancelRequest { |
| - cancel() |
| + if mode != "deadline" { |
| + cancel() |
| + } |
| <-ctx.Done() |
| } |
| if mode == "empty-error" { |
| @@ -134,12 +142,18 @@ func TestRemoteRetirementArchiveAndCancellation(t *testing.T) { |
| if mode == "empty-error" && err != callbackError { |
| t.Fatalf("empty callback error = %v", err) |
| } |
| - if cancelRequest && !errors.Is(err, context.Canceled) { |
| + if mode == "deadline" && !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("deadline = %v", err) |
| + } |
| + if mode == "cancel" && !errors.Is(err, context.Canceled) { |
| t.Fatalf("cancel = %v", err) |
| } |
| if mode == "commit" && err != nil { |
| t.Fatal(err) |
| } |
| + if _, err := r.Get("claim"); err != nil { |
| + t.Fatalf("call after %s: %v", mode, err) |
| + } |
| local, err := Open(path) |
| if err != nil { |
| t.Fatal(err) |
| @@ -175,3 +189,51 @@ func TestRemoteCallbackPanicRollsBack(t *testing.T) { |
| t.Fatalf("lock remained held: %v", err) |
| } |
| } |
| + |
| +func TestRemoteReconnectAfterTransportDies(t *testing.T) { |
| + r, _ := remoteTestRegistry(t) |
| + if err := r.Upsert(Route{Host: "survives", Kind: "instance"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.remote.output.Close() |
| + if _, err := r.Get("survives"); err == nil { |
| + t.Fatal("broken transport unexpectedly succeeded") |
| + } |
| + row, err := r.Get("survives") |
| + if err != nil || row == nil { |
| + t.Fatalf("reconnected read: %+v, %v", row, err) |
| + } |
| + if err := r.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := r.Get("survives"); !errors.Is(err, os.ErrClosed) { |
| + t.Fatalf("closed registry reopened: %v", err) |
| + } |
| +} |
| + |
| +func TestRemoteCancellationDuringReconnect(t *testing.T) { |
| + r, _ := remoteTestRegistry(t) |
| + connect := r.remote.connect |
| + if err := r.remote.disconnect(); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.remote.connect = func() (io.WriteCloser, io.ReadCloser, func() error, error) { |
| + requestR, requestW := io.Pipe() |
| + responseR, responseW := io.Pipe() |
| + return requestW, responseR, func() error { |
| + requestR.Close() |
| + responseW.Close() |
| + return nil |
| + }, nil |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) |
| + defer cancel() |
| + err := r.SetClaimHold(ctx, "unused", ClaimOwnership{}, "", "") |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("startup cancellation = %v", err) |
| + } |
| + r.remote.connect = connect |
| + if _, err := r.Get("unused"); err != nil { |
| + t.Fatalf("call after startup cancellation: %v", err) |
| + } |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index f7aafa322..debba5df8 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -21,6 +21,7 @@ import ( |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // adoptPlatform picks which manifest artifact a box installs — a var so |
| @@ -1909,7 +1910,7 @@ func ensureStagedDigest(ctx context.Context, s Store, optDir, acct, app, ver, ho |
| // reads but is some other manifest's bytes (an older binary's re-marshal, or |
| // a release since replaced under the name). Best-effort, like repairSibling. |
| func syncSibling(optDir, acct, app, ver, host string, raw []byte) { |
| - if cur, err := os.ReadFile(StagedManifestPath(optDir, acct, ver, app)); err == nil && bytes.Equal(cur, raw) { |
| + if cur, err := safefile.ReadFile(StagedManifestPath(optDir, acct, ver, app)); err == nil && bytes.Equal(cur, raw) { |
| return |
| } |
| if err := StageManifest(optDir, acct, ver, app, raw); err != nil { |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index d4efb3f95..1ffd286c8 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -1070,8 +1070,8 @@ func TestEnsureStagedBackfillsASiblingForAnAlreadyStagedBinary(t *testing.T) { |
| // its sibling cannot be written — a missing sibling is the safe leave-alone |
| // state, and backfillManifest gets another chance at it every later pass. |
| // |
| -// StageManifest's own write is blocked by pre-creating its ".tmp" rename |
| -// target AS A DIRECTORY — os.WriteFile then fails to open it, without ever |
| +// StageManifest's own write is blocked by pre-creating its final rename |
| +// target AS A DIRECTORY — rename then fails to replace it, without ever |
| // touching StageArtifact's own (different) binary path. |
| func TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| @@ -1087,7 +1087,7 @@ func TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary(t *testing. |
| t.Fatalf("Ship: %v", err) |
| } |
| - tmpBlock := StagedManifestPath(opt, "bab", "v1abc", "keymail") + ".tmp" |
| + tmpBlock := StagedManifestPath(opt, "bab", "v1abc", "keymail") |
| if err := os.MkdirAll(tmpBlock, 0o755); err != nil { |
| t.Fatalf("pre-create tmp block: %v", err) |
| } |
| @@ -1108,8 +1108,8 @@ func TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary(t *testing. |
| if err != nil || string(body) != "relay binary" { |
| t.Fatalf("binary did not stage despite the sibling write failing: %q, %v", body, err) |
| } |
| - if got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err != nil || got != nil { |
| - t.Fatalf("sibling = %+v, %v; want absent (the write genuinely failed)", got, err) |
| + if got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err == nil || got != nil { |
| + t.Fatalf("sibling = %+v, %v; want blocked (the write genuinely failed)", got, err) |
| } |
| } |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index 4964572ac..2ff93a2b0 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -31,6 +31,8 @@ import ( |
| "path/filepath" |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // DefaultHolds is the default bake window per channel; per-deployment |
| @@ -205,7 +207,7 @@ const clockSep = "|" |
| // buys it nothing a corrupt bucket object would. |
| func LoadFirstSeen(path string) *FirstSeen { |
| st := &FirstSeen{path: path} |
| - if raw, err := os.ReadFile(path); err == nil { |
| + if raw, err := safefile.ReadFile(path); err == nil { |
| if err := json.Unmarshal(raw, st); err != nil { |
| st.tainted = true |
| } |
| @@ -330,30 +332,7 @@ func (st *FirstSeen) Save() error { |
| return err |
| } |
| raw = append(raw, '\n') |
| - f, err := os.CreateTemp(filepath.Dir(st.path), "channel-first-seen-*.json") |
| - if err != nil { |
| - return err |
| - } |
| - tmp := f.Name() |
| - _, writeErr := f.Write(raw) |
| - closeErr := f.Close() |
| - if writeErr != nil { |
| - os.Remove(tmp) |
| - return writeErr |
| - } |
| - if closeErr != nil { |
| - os.Remove(tmp) |
| - return closeErr |
| - } |
| - if err := os.Chmod(tmp, 0o644); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := os.Rename(tmp, st.path); err != nil { |
| - os.Remove(tmp) // don't leave a stray temp file behind |
| - return err |
| - } |
| - return nil |
| + return safefile.WriteFile(st.path, raw, 0o644) |
| } |
| // EligibleAt is the moment a version first seen at a channel head may be |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index 97280d87b..d23e7c2d6 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -338,9 +338,9 @@ func TestFirstSeenSaveReplacesFileItCannotWriteInPlace(t *testing.T) { |
| t.Fatalf("Save must replace via rename, not an in-place write: %v", err) |
| } |
| // The temp name is minted fresh per attempt (CreateTemp's |
| - // "channel-first-seen-*.json"), not a fixed "<path>.tmp" — glob for the |
| + // ".carlos-write-*"), not a fixed "<path>.tmp" — glob for the |
| // pattern rather than stat-ing one fixed name. |
| - if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "channel-first-seen-*.json")); err != nil { |
| + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".carlos-write-*")); err != nil { |
| t.Fatalf("glob for stray temp files: %v", err) |
| } else if len(matches) != 0 { |
| t.Fatalf("Save left a temp file behind: %v", matches) |
diff --git a/internal/release/conditional_delete.go b/internal/release/conditional_delete.go| index f3233809a..67917e6a6 100644 |
| --- a/internal/release/conditional_delete.go |
| +++ b/internal/release/conditional_delete.go |
| @@ -9,6 +9,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/aws" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| var ( |
| @@ -69,7 +70,7 @@ func (d *DirStore) DeleteIfMatch(_ context.Context, key, etag string) error { |
| } |
| d.mu.Lock() |
| defer d.mu.Unlock() |
| - body, err := os.ReadFile(path) |
| + body, err := safefile.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| return nil |
| } |
| @@ -79,7 +80,7 @@ func (d *DirStore) DeleteIfMatch(_ context.Context, key, etag string) error { |
| if etagOf(body) != etag { |
| return fmt.Errorf("dirstore: %s: %w", key, ErrCASConflict) |
| } |
| - return os.Remove(path) |
| + return safefile.Remove(path) |
| } |
| func (s *S3Store) DeleteIfMatch(ctx context.Context, key, etag string) error { |
diff --git a/internal/release/fetch.go b/internal/release/fetch.go| index 4c3d33825..726f1e91c 100644 |
| --- a/internal/release/fetch.go |
| +++ b/internal/release/fetch.go |
| @@ -8,7 +8,8 @@ import ( |
| "fmt" |
| "io" |
| "os" |
| - "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // ErrTooLarge: the object exceeded the caller's limit. The reader is cut at |
| @@ -24,38 +25,20 @@ func FetchToFile(ctx context.Context, s Store, key, dst string, limit int64, wan |
| return err |
| } |
| defer rc.Close() |
| - tmp := dst + ".tmp" |
| - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) |
| - if err != nil { |
| - return err |
| - } |
| - defer func() { |
| + return safefile.Write(dst, 0o600, func(f io.Writer) error { |
| + h := sha256.New() |
| + n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(rc, limit+1)) |
| if err != nil { |
| - f.Close() |
| - os.Remove(tmp) |
| + return fmt.Errorf("fetch %s: %w", key, err) |
| } |
| - }() |
| - h := sha256.New() |
| - n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(rc, limit+1)) |
| - if err != nil { |
| - return fmt.Errorf("fetch %s: %w", key, err) |
| - } |
| - if n > limit { |
| - return fmt.Errorf("fetch %s: %w (limit %d)", key, ErrTooLarge, limit) |
| - } |
| - if got := hex.EncodeToString(h.Sum(nil)); got != wantSHA256 { |
| - return fmt.Errorf("fetch %s: sha256 %s does not match the signed %s", key, got, wantSHA256) |
| - } |
| - if err = f.Sync(); err != nil { |
| - return err |
| - } |
| - if err = f.Close(); err != nil { |
| - return err |
| - } |
| - if err = os.Rename(tmp, dst); err != nil { |
| - return err |
| - } |
| - return syncDir(filepath.Dir(dst)) |
| + if n > limit { |
| + return fmt.Errorf("fetch %s: %w (limit %d)", key, ErrTooLarge, limit) |
| + } |
| + if got := hex.EncodeToString(h.Sum(nil)); got != wantSHA256 { |
| + return fmt.Errorf("fetch %s: sha256 %s does not match the signed %s", key, got, wantSHA256) |
| + } |
| + return nil |
| + }) |
| } |
| // syncDir makes a rename durable: the directory entry is what fsync(file) |
diff --git a/internal/release/generations_publish_test.go b/internal/release/generations_publish_test.go| index 17dab8f60..cdddab1ec 100644 |
| --- a/internal/release/generations_publish_test.go |
| +++ b/internal/release/generations_publish_test.go |
| @@ -243,7 +243,7 @@ func TestGenerationVersionsNameTheVersionDirectories(t *testing.T) { |
| t.Fatal(err) |
| } |
| for _, v := range []string{"v2", "v3"} { |
| - if err := StageArtifact(opt, "kqmv", v, "km", []byte(v)); err != nil { |
| + if err := StageArtifact(real, "kqmv", v, "km", []byte(v)); err != nil { |
| t.Fatal(err) |
| } |
| } |
diff --git a/internal/release/healthy.go b/internal/release/healthy.go| index c6f9fb31f..52bc4ed04 100644 |
| --- a/internal/release/healthy.go |
| +++ b/internal/release/healthy.go |
| @@ -15,9 +15,10 @@ import ( |
| "encoding/json" |
| "fmt" |
| "log" |
| - "os" |
| "path/filepath" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| const healthyMax = 50 |
| @@ -46,7 +47,7 @@ func ReadHealthy(bakeDir, acct, app string) []HealthyBuild { |
| if err != nil { |
| return nil |
| } |
| - b, err := os.ReadFile(p) |
| + b, err := safefile.ReadFile(p) |
| if err != nil { |
| return nil |
| } |
| @@ -95,24 +96,8 @@ func RecordHealthy(bakeDir, acct, app string, b HealthyBuild) error { |
| if err != nil { |
| return err |
| } |
| - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| - return err |
| - } |
| - tmp, err := os.CreateTemp(filepath.Dir(p), ".healthy-*") |
| - if err != nil { |
| - return err |
| - } |
| - defer os.Remove(tmp.Name()) |
| - if _, err := tmp.Write(append(body, '\n')); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Chmod(0o644); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Close(); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| return err |
| } |
| - return os.Rename(tmp.Name(), p) |
| + return safefile.WriteFile(p, append(body, '\n'), 0o644) |
| } |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| index c36d618a0..fd096b9b9 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -5,15 +5,17 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| "os" |
| "path/filepath" |
| + "sort" |
| "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/controlname" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| - "github.com/carlosframework/platform/internal/instancefs" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // ErrStageBlocked is MaterializeHeld reporting that this process cannot |
| @@ -443,17 +445,23 @@ const heldStageRequestMax = 4096 |
| // Before staging it is called with a nil then. |
| // |
| // The directory is agent-owned and this runs as root, so every read, write |
| -// and removal goes through instancefs: no symlink is followed at any |
| -// component, and nothing but a regular file is read. |
| +// and removal goes through safefile: no symlink is followed at any |
| +// component, and only a regular file with one link is read. |
| func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string, proof *ProofGate, authorize func(req HeldStageRequest, then func() error) error) error { |
| reqDir := HeldStageDir(dataDir) |
| - ents, err := os.ReadDir(reqDir) |
| + dir, err := safefile.OpenDir(reqDir) |
| if errors.Is(err, os.ErrNotExist) { |
| return nil |
| } |
| if err != nil { |
| return err |
| } |
| + defer dir.Close() |
| + ents, err := dir.ReadDir(-1) |
| + if err != nil { |
| + return err |
| + } |
| + sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() }) |
| for _, e := range ents { |
| name := e.Name() |
| if strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".json") { |
| @@ -467,11 +475,11 @@ func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string |
| } |
| // serveHeldStageRequest serves one request file. It returns an error only |
| -// when this build cannot do the work at all (instancefs unsupported). |
| +// when this build cannot do the work at all. |
| func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir, name string, proof *ProofGate, authorize func(HeldStageRequest, func() error) error) error { |
| drop := func(format string, args ...any) error { |
| fmt.Printf(" held stage request %q: "+format+"; removed\n", append([]any{name}, args...)...) |
| - if err := instancefs.RemoveBeneath(reqDir, name); err != nil { |
| + if err := safefile.Remove(filepath.Join(reqDir, name)); err != nil { |
| if errors.Is(err, errors.ErrUnsupported) { |
| return err |
| } |
| @@ -479,13 +487,24 @@ func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir |
| } |
| return nil |
| } |
| - body, err := instancefs.ReadBeneathLimit(reqDir, name, heldStageRequestMax) |
| + f, err := safefile.OpenFile(filepath.Join(reqDir, name), os.O_RDONLY, 0) |
| if err != nil { |
| if errors.Is(err, errors.ErrUnsupported) { |
| return err |
| } |
| return drop("unreadable: %v", err) |
| } |
| + body, err := io.ReadAll(io.LimitReader(f, heldStageRequestMax+1)) |
| + closeErr := f.Close() |
| + if err != nil { |
| + return drop("unreadable: %v", err) |
| + } |
| + if closeErr != nil { |
| + return drop("unreadable: %v", closeErr) |
| + } |
| + if len(body) > heldStageRequestMax { |
| + return drop("unreadable: request exceeds %d bytes", heldStageRequestMax) |
| + } |
| var req HeldStageRequest |
| if err := json.Unmarshal(body, &req); err != nil { |
| return drop("not a request") |
| @@ -510,17 +529,17 @@ func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir |
| } |
| raw, err := json.Marshal(HeldStaged{Version: req.Version, Op: req.Op, At: time.Now().UTC()}) |
| if err == nil { |
| - _, err = instancefs.EnsureDirBeneath(filepath.Dir(HeldStagedDir(dataDir)), filepath.Base(HeldStagedDir(dataDir)), 0o755) |
| + err = safefile.MkdirAll(HeldStagedDir(dataDir), 0o755) |
| } |
| if err == nil { |
| - err = instancefs.WriteAtomic(HeldStagedDir(dataDir), req.Host+".json", raw, 0o644) |
| + err = safefile.WriteFile(filepath.Join(HeldStagedDir(dataDir), req.Host+".json"), raw, 0o644) |
| } |
| if err != nil { |
| fmt.Printf(" %s: held stage of %s for %s: live, but the ack was not written: %v; kept for the next pass\n", req.Host, req.Version, req.Op, err) |
| return nil |
| } |
| fmt.Printf(" %s: held stage of %s for %s: live\n", req.Host, req.Version, req.Op) |
| - if err := instancefs.RemoveBeneath(reqDir, name); err != nil { |
| + if err := safefile.Remove(filepath.Join(reqDir, name)); err != nil { |
| fmt.Printf(" held stage request %q: served, but not removed: %v\n", name, err) |
| } |
| return nil |
diff --git a/internal/release/materializeheld_test.go b/internal/release/materializeheld_test.go| index 25c7e45df..c8a34da37 100644 |
| --- a/internal/release/materializeheld_test.go |
| +++ b/internal/release/materializeheld_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "fmt" |
| "os" |
| "path/filepath" |
| + "runtime" |
| "sync" |
| "syscall" |
| "testing" |
| @@ -386,3 +387,39 @@ func offGate(t *testing.T) *ProofGate { |
| t.Helper() |
| return NewProofGate(filepath.Join(t.TempDir(), "proof-policy.json"), t.TempDir()) |
| } |
| + |
| +func TestHeldStageHardlinkedRequestIsRefused(t *testing.T) { |
| + if runtime.GOOS != "linux" { |
| + t.Skip("Linux descriptor boundary") |
| + } |
| + ctx, s, opt := heldFixture(t) |
| + dd := t.TempDir() |
| + target := filepath.Join(t.TempDir(), "target.json") |
| + raw, err := json.Marshal(goodHeldRequest()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(target, raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(HeldStageDir(dd), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + request := filepath.Join(HeldStageDir(dd), heldTestHost+".json") |
| + if err := os.Link(target, request); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := false |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| + t.Fatal(err) |
| + } |
| + if called { |
| + t.Fatal("hardlinked request reached authorize") |
| + } |
| + gone(t, request) |
| + gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| + after, err := os.ReadFile(target) |
| + if err != nil || string(after) != string(raw) { |
| + t.Fatalf("hardlink target changed: %q %v", after, err) |
| + } |
| +} |
diff --git a/internal/release/pins.go b/internal/release/pins.go| index 9dcc26cb1..3aa175333 100644 |
| --- a/internal/release/pins.go |
| +++ b/internal/release/pins.go |
| @@ -16,6 +16,7 @@ import ( |
| "strings" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // StagedPaths is a set of staged artifact paths (VersionPath values, held |
| @@ -190,7 +191,7 @@ func ReadGenerations(path string) (*Generations, error) { |
| if err != nil { |
| return nil, err |
| } |
| - raw, err := os.ReadFile(path) |
| + raw, err := safefile.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| if !hasIntent { |
| return nil, nil |
| @@ -223,7 +224,7 @@ func ReadGenerations(path string) (*Generations, error) { |
| // absent, an error when it cannot be read or does not hold a sequence of 1 or |
| // more. |
| func readGenerationsSeq(seqPath string) (uint64, bool, error) { |
| - raw, err := os.ReadFile(seqPath) |
| + raw, err := safefile.ReadFile(seqPath) |
| if errors.Is(err, os.ErrNotExist) { |
| return 0, false, nil |
| } |
| @@ -246,7 +247,7 @@ func readGenerationsSeq(seqPath string) (uint64, bool, error) { |
| func nextGenerationsSeq(path string) (uint64, error) { |
| var n uint64 |
| seqPath := GenerationsSeqPath(path) |
| - raw, err := os.ReadFile(seqPath) |
| + raw, err := safefile.ReadFile(seqPath) |
| switch { |
| case err == nil: |
| if v, perr := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64); perr == nil { |
| @@ -255,7 +256,7 @@ func nextGenerationsSeq(path string) (uint64, error) { |
| case !errors.Is(err, os.ErrNotExist): |
| return 0, fmt.Errorf("generations intent %s: %w", seqPath, err) |
| } |
| - if body, err := os.ReadFile(path); err == nil { |
| + if body, err := safefile.ReadFile(path); err == nil { |
| var g Generations |
| if json.Unmarshal(body, &g) == nil && g.Seq > n { |
| n = g.Seq |
| @@ -281,7 +282,7 @@ func WriteGenerations(path string, g Generations) error { |
| if err := validGenerations(g); err != nil { |
| return err |
| } |
| - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| return err |
| } |
| seq, err := nextGenerationsSeq(path) |
| @@ -302,27 +303,7 @@ func WriteGenerations(path string, g Generations) error { |
| // writeFileAtomic replaces path with body: a temp file in path's directory, |
| // written, mode 0644, fsynced, closed, then renamed over path. |
| func writeFileAtomic(path string, body []byte) error { |
| - tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") |
| - if err != nil { |
| - return err |
| - } |
| - defer os.Remove(tmp.Name()) |
| - if _, err := tmp.Write(body); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Chmod(0o644); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Sync(); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Close(); err != nil { |
| - return err |
| - } |
| - return os.Rename(tmp.Name(), path) |
| + return safefile.WriteFile(path, body, 0o644) |
| } |
| func validGenerations(g Generations) error { |
diff --git a/internal/release/pins_test.go b/internal/release/pins_test.go| index 1a1ef766b..bf92f2794 100644 |
| --- a/internal/release/pins_test.go |
| +++ b/internal/release/pins_test.go |
| @@ -270,7 +270,7 @@ func TestBoxPinsRelativeLinkUnderASymlinkedOptDir(t *testing.T) { |
| if err := os.Symlink(real, opt); err != nil { |
| t.Fatal(err) |
| } |
| - if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| + if err := StageArtifact(real, "bab", "v1", "km", []byte("v1")); err != nil { |
| t.Fatal(err) |
| } |
| if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| @@ -328,7 +328,7 @@ func TestStagedPathsRefersComparesTheRealPath(t *testing.T) { |
| if err := os.Symlink(real, opt); err != nil { |
| t.Fatal(err) |
| } |
| - if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| + if err := StageArtifact(real, "bab", "v1", "km", []byte("v1")); err != nil { |
| t.Fatal(err) |
| } |
| pins := StagedPaths{} |
diff --git a/internal/release/programhistory.go b/internal/release/programhistory.go| index e9ac293a6..66fa5f12f 100644 |
| --- a/internal/release/programhistory.go |
| +++ b/internal/release/programhistory.go |
| @@ -6,10 +6,11 @@ package release |
| import ( |
| "encoding/json" |
| - "os" |
| "path/filepath" |
| "slices" |
| "strings" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| func programPath(bakeDir, host string) string { |
| @@ -20,7 +21,7 @@ func ReadPrograms(bakeDir, host string) []string { |
| if strings.ContainsAny(host, `/\`) { |
| return nil |
| } |
| - b, err := os.ReadFile(programPath(bakeDir, host)) |
| + b, err := safefile.ReadFile(programPath(bakeDir, host)) |
| if err != nil { |
| return nil |
| } |
| @@ -49,12 +50,8 @@ func RecordProgram(bakeDir, host, program string) error { |
| return err |
| } |
| p := programPath(bakeDir, host) |
| - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| - return err |
| - } |
| - tmp := p + ".tmp" |
| - if err := os.WriteFile(tmp, b, 0o644); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| return err |
| } |
| - return os.Rename(tmp, p) |
| + return safefile.WriteFile(p, b, 0o644) |
| } |
diff --git a/internal/release/proofpolicy.go b/internal/release/proofpolicy.go| index c18a36454..d1f925b2e 100644 |
| --- a/internal/release/proofpolicy.go |
| +++ b/internal/release/proofpolicy.go |
| @@ -25,6 +25,8 @@ import ( |
| "os" |
| "path/filepath" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| const ( |
| @@ -65,7 +67,7 @@ func LoadProofPolicy(path string) (*ProofPolicy, error) { |
| if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { |
| return nil, nil |
| } |
| - b, err := os.ReadFile(path) |
| + b, err := safefile.ReadFile(path) |
| if err != nil { |
| return nil, fmt.Errorf("proof policy %s: %w", path, err) |
| } |
diff --git a/internal/release/ratchet.go b/internal/release/ratchet.go| index 3fb553378..2ffcb286e 100644 |
| --- a/internal/release/ratchet.go |
| +++ b/internal/release/ratchet.go |
| @@ -12,6 +12,8 @@ import ( |
| "sync/atomic" |
| "syscall" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // RequirePointerDigestEnv is the box flag that refuses every channel pointer |
| @@ -160,7 +162,7 @@ func (r *DigestRatchet) note(acct, app, channel string) error { |
| if fi, err := os.Stat(path); err == nil && fi.Mode().IsRegular() { |
| return nil |
| } |
| - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| log.Printf("release: pointer digest ratchet: %v", err) |
| return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) |
| } |
diff --git a/internal/release/refusal.go b/internal/release/refusal.go| index 7d61a2afc..7596952c0 100644 |
| --- a/internal/release/refusal.go |
| +++ b/internal/release/refusal.go |
| @@ -27,6 +27,8 @@ import ( |
| "sort" |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| ) |
| const refusalSchema = 1 |
| @@ -96,26 +98,10 @@ func WriteRefusal(bakeDir string, r Refusal) error { |
| if err != nil { |
| return err |
| } |
| - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| - return err |
| - } |
| - tmp, err := os.CreateTemp(filepath.Dir(p), ".refusal-*") |
| - if err != nil { |
| - return err |
| - } |
| - defer os.Remove(tmp.Name()) |
| - if _, err := tmp.Write(append(b, '\n')); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Chmod(0o644); err != nil { |
| - tmp.Close() |
| - return err |
| - } |
| - if err := tmp.Close(); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| return err |
| } |
| - return os.Rename(tmp.Name(), p) |
| + return safefile.WriteFile(p, append(b, '\n'), 0o644) |
| } |
| func ReadRefusal(bakeDir, host string) (*Refusal, error) { |
| @@ -123,7 +109,7 @@ func ReadRefusal(bakeDir, host string) (*Refusal, error) { |
| if err != nil { |
| return nil, err |
| } |
| - b, err := os.ReadFile(p) |
| + b, err := safefile.ReadFile(p) |
| if errors.Is(err, os.ErrNotExist) { |
| return nil, nil |
| } |
| @@ -167,7 +153,7 @@ func ClearRefusal(bakeDir, host string) error { |
| if err != nil { |
| return err |
| } |
| - if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + if err := safefile.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return err |
| } |
| return nil |
diff --git a/internal/release/stage_reuse_test.go b/internal/release/stage_reuse_test.go| index 3d45f7fa2..d50c5804a 100644 |
| --- a/internal/release/stage_reuse_test.go |
| +++ b/internal/release/stage_reuse_test.go |
| @@ -318,16 +318,16 @@ func TestStageHeldNeverRestagesADifferingCopy(t *testing.T) { |
| // Astra r3 N4, ruling R19 item 5: the opt dir is itself a symlink |
| // (/opt/carlos -> /srv/carlos), and live/h2 reaches the stale v2 through two |
| // relative hops: live/h2 -> ../current, current -> versions/v2/bab/myapp. |
| -// The hops resolve under the real dir, so no pin is spelt the way VersionPath |
| -// spells the copy under the opt dir's own name. The restage's membership |
| -// check compares the copy's real path too, and refuses. |
| +// The hops resolve under the real dir. The pass now refuses the symlinked |
| +// opt directory before writing its bake clock, preserving the staged copy. |
| func TestStaleCopyTwoRelativeHopsUnderASymlinkedOptDirNameIsNotOverwritten(t *testing.T) { |
| real := t.TempDir() |
| opt := filepath.Join(t.TempDir(), "opt") |
| if err := os.Symlink(real, opt); err != nil { |
| t.Fatal(err) |
| } |
| - f := newReuseFixtureAt(t, opt) |
| + f := newReuseFixtureAt(t, real) |
| + f.opt = opt |
| rel, err := filepath.Rel(opt, VersionPath(opt, "bab", "v2", "myapp")) |
| if err != nil { |
| t.Fatal(err) |
| @@ -345,5 +345,10 @@ func TestStaleCopyTwoRelativeHopsUnderASymlinkedOptDirNameIsNotOverwritten(t *te |
| if pins.Has(VersionPath(opt, "bab", "v2", "myapp")) { |
| t.Fatalf("setup: BoxPins = %v already names the copy lexically; the test would not exercise the real-path check", pins) |
| } |
| - f.assertRefused(t, f.pass(t), ErrVersionReused.Error()) |
| + err = Pass(context.Background(), f.reg, f.s, f.pub, f.opt, func(string) error { return nil }) |
| + if err == nil { |
| + t.Fatal("pass accepted a symlinked opt directory") |
| + } |
| + f.opt = real |
| + f.assertRefused(t, err.Error(), "record bake clock") |
| } |
diff --git a/internal/release/stage_stream_test.go b/internal/release/stage_stream_test.go| index 316dcd6a9..60fffa519 100644 |
| --- a/internal/release/stage_stream_test.go |
| +++ b/internal/release/stage_stream_test.go |
| @@ -144,7 +144,7 @@ func TestStageArtifactFromLeavesTheStagedCopyOnFailure(t *testing.T) { |
| if got, _ := os.ReadFile(path); string(got) != "old build" { |
| t.Errorf("staged copy is %q, want the old build untouched", got) |
| } |
| - if left, _ := filepath.Glob(path + ".tmp*"); len(left) != 0 { |
| + if left, _ := filepath.Glob(filepath.Join(filepath.Dir(path), ".carlos-write-*")); len(left) != 0 { |
| t.Errorf("temp files left behind: %v", left) |
| } |
| }) |
| @@ -201,7 +201,7 @@ func TestStageArtifactFromOverlappingWriters(t *testing.T) { |
| if got, _ := os.ReadFile(path); !bytes.Equal(got, good) { |
| t.Fatalf("staged %q, want writer A's verified bytes %q", got, good) |
| } |
| - if left, _ := filepath.Glob(path + ".tmp*"); len(left) != 0 { |
| + if left, _ := filepath.Glob(filepath.Join(filepath.Dir(path), ".carlos-write-*")); len(left) != 0 { |
| t.Errorf("temp files left behind: %v", left) |
| } |
| } |
diff --git a/internal/release/state_security_test.go b/internal/release/state_security_test.go| new file mode 100644 |
| index 000000000..ba8812e29 |
| --- /dev/null |
| +++ b/internal/release/state_security_test.go |
| @@ -0,0 +1,60 @@ |
| +package release |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestStateWritersRefuseSymlinkParents(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, parent string |
| + write func(string) error |
| + }{ |
| + {"program", "programs", func(dir string) error { return RecordProgram(dir, "app.example", "example.org/app") }}, |
| + {"healthy", "healthy", func(dir string) error { |
| + return RecordHealthy(dir, "bab", "app", HealthyBuild{SHA256: strings.Repeat("a", 64)}) |
| + }}, |
| + {"refusal", "refusals", func(dir string) error { return WriteRefusal(dir, Refusal{Host: "app.example"}) }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + dir, outside := t.TempDir(), t.TempDir() |
| + if err := os.Symlink(outside, filepath.Join(dir, tc.parent)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tc.write(dir); err == nil { |
| + t.Fatal("write followed a symlink parent") |
| + } |
| + entries, err := os.ReadDir(outside) |
| + if err != nil || len(entries) != 0 { |
| + t.Fatalf("outside directory changed: %v %v", entries, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRecordProgramIgnoresPlantedLegacyTemporarySymlink(t *testing.T) { |
| + dir := t.TempDir() |
| + victim := filepath.Join(dir, "victim") |
| + if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := programPath(dir, "app.example") |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(victim, p+".tmp"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RecordProgram(dir, "app.example", "example.org/app"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := os.ReadFile(victim) |
| + if err != nil || string(got) != "untouched" { |
| + t.Fatalf("victim changed: %q %v", got, err) |
| + } |
| + if got := ReadPrograms(dir, "app.example"); len(got) != 1 || got[0] != "example.org/app" { |
| + t.Fatalf("program not recorded: %v", got) |
| + } |
| +} |
diff --git a/internal/release/store.go b/internal/release/store.go| index 57449cf47..aef07aeac 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -25,6 +25,7 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| "github.com/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/safefile" |
| "github.com/carlosframework/platform/internal/s3compat" |
| ) |
| @@ -349,7 +350,7 @@ func (d *DirStore) path(key string) (string, error) { |
| // hashFile is etagOf over a file, streamed. |
| func hashFile(p string) (string, error) { |
| - f, err := os.Open(p) |
| + f, err := safefile.OpenFile(p, os.O_RDONLY, 0) |
| if err != nil { |
| return "", err |
| } |
| @@ -373,7 +374,7 @@ func (d *DirStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| } |
| d.mu.Lock() |
| defer d.mu.Unlock() |
| - b, err := os.ReadFile(p) |
| + b, err := safefile.ReadFile(p) |
| if err != nil { |
| if errors.Is(err, fs.ErrNotExist) { |
| return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist) |
| @@ -388,7 +389,7 @@ func (d *DirStore) Open(_ context.Context, key string) (io.ReadCloser, string, e |
| if err != nil { |
| return nil, "", err |
| } |
| - f, err := os.Open(p) |
| + f, err := safefile.OpenFile(p, os.O_RDONLY, 0) |
| if err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist) |
| @@ -428,17 +429,10 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin |
| return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict) |
| } |
| } |
| - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| return "", err |
| } |
| - // Temp + rename so a crashed write never leaves a half-object a reader |
| - // could mistake for the real thing. |
| - tmp := p + ".tmp" |
| - if err := os.WriteFile(tmp, body, 0o644); err != nil { |
| - return "", err |
| - } |
| - if err := os.Rename(tmp, p); err != nil { |
| - os.Remove(tmp) |
| + if err := safefile.WriteFile(p, body, 0o644); err != nil { |
| return "", err |
| } |
| return etagOf(body), nil |
| @@ -452,25 +446,9 @@ func (d *DirStore) PutFrom(_ context.Context, key string, body io.ReadSeeker, si |
| if err != nil { |
| return "", err |
| } |
| - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| - return "", err |
| - } |
| - tmp, err := os.CreateTemp(filepath.Dir(p), filepath.Base(p)+".*.tmp") |
| - if err != nil { |
| - return "", err |
| - } |
| - defer os.Remove(tmp.Name()) |
| - h := sha256.New() |
| - n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(body, size+1)) |
| - if cerr := tmp.Close(); err == nil { |
| - err = cerr |
| - } |
| - if err != nil { |
| + if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| return "", err |
| } |
| - if n != size { |
| - return "", fmt.Errorf("dirstore: %s: body is %d bytes, want %d", key, n, size) |
| - } |
| d.mu.Lock() |
| defer d.mu.Unlock() |
| // The CAS gate without reading the existing object into memory (it may |
| @@ -494,10 +472,18 @@ func (d *DirStore) PutFrom(_ context.Context, key string, body io.ReadSeeker, si |
| return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict) |
| } |
| } |
| - if err := os.Chmod(tmp.Name(), 0o644); err != nil { |
| - return "", err |
| - } |
| - if err := os.Rename(tmp.Name(), p); err != nil { |
| + h := sha256.New() |
| + err = safefile.Write(p, 0o644, func(w io.Writer) error { |
| + n, err := io.Copy(io.MultiWriter(w, h), io.LimitReader(body, size+1)) |
| + if err != nil { |
| + return err |
| + } |
| + if n != size { |
| + return fmt.Errorf("dirstore: %s: body is %d bytes, want %d", key, n, size) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| return "", err |
| } |
| return hex.EncodeToString(h.Sum(nil)), nil |
| @@ -546,6 +532,9 @@ func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) { |
| } |
| return err |
| } |
| + if de.IsDir() && strings.HasPrefix(de.Name(), ".carlos-write-") { |
| + return filepath.SkipDir |
| + } |
| if de.IsDir() || strings.HasSuffix(p, ".tmp") { |
| return nil |
| } |
| @@ -588,6 +577,9 @@ func (d *DirStore) ListSizes(_ context.Context, prefix string) ([]KeySize, error |
| } |
| return err |
| } |
| + if de.IsDir() && strings.HasPrefix(de.Name(), ".carlos-write-") { |
| + return filepath.SkipDir |
| + } |
| if de.IsDir() || strings.HasSuffix(p, ".tmp") { |
| return nil |
| } |
| @@ -644,7 +636,7 @@ func (d *DirStore) ListDirs(_ context.Context, prefix string) ([]string, error) |
| } |
| var out []string |
| for _, e := range ents { |
| - if !e.IsDir() { |
| + if !e.IsDir() || strings.HasPrefix(e.Name(), ".carlos-write-") { |
| continue |
| } |
| out = append(out, prefix+e.Name()+"/") |
| @@ -660,7 +652,7 @@ func (d *DirStore) Delete(_ context.Context, key string) error { |
| } |
| d.mu.Lock() |
| defer d.mu.Unlock() |
| - if err := os.Remove(p); err != nil { |
| + if err := safefile.Remove(p); err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| return nil // idempotent: missing key is not an error |
| } |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index 8f44118e0..742148727 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -28,6 +28,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/safefile" |
| "github.com/carlosframework/platform/internal/unixdial" |
| ) |
| @@ -77,53 +78,18 @@ func ValidVersion(v string) bool { |
| // never sees a half-written binary (and a crashed copy leaves the old one |
| // intact). |
| func CopyFileAtomic(src, dst string, mode os.FileMode) error { |
| - in, err := os.Open(src) |
| + in, err := safefile.OpenFile(src, os.O_RDONLY, 0) |
| if err != nil { |
| return err |
| } |
| defer in.Close() |
| - tmp := dst + ".tmp" |
| - out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) |
| - if err != nil { |
| - return err |
| - } |
| - if _, err := io.Copy(out, in); err != nil { |
| - out.Close() |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := out.Close(); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - // O_CREATE's mode is masked by umask; set it explicitly so the binary is +x. |
| - if err := os.Chmod(tmp, mode); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := os.Rename(tmp, dst); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - return nil |
| + return safefile.Write(dst, mode, func(out io.Writer) error { _, err := io.Copy(out, in); return err }) |
| } |
| // WriteFileAtomic is CopyFileAtomic for in-memory bytes (a downloaded, |
| // sha256-verified artifact). |
| func WriteFileAtomic(dst string, data []byte, mode os.FileMode) error { |
| - tmp := dst + ".tmp" |
| - if err := os.WriteFile(tmp, data, mode); err != nil { |
| - return err |
| - } |
| - if err := os.Chmod(tmp, mode); err != nil { // umask, as above |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := os.Rename(tmp, dst); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - return nil |
| + return safefile.WriteFile(dst, data, mode) |
| } |
| // StageArtifact puts verified artifact bytes into the store under ver, so |
| @@ -162,40 +128,16 @@ func stageArtifactFrom(optDir, acct, ver, app, wantSHA string, open func() (io.R |
| return fmt.Errorf("download %s: %w", ver, err) |
| } |
| defer in.Close() |
| - // A temp file of its own, not WriteFileAtomic's shared dst+".tmp": a |
| - // download is long enough for a concurrent writer of the same version (an |
| - // unserialized pass, the agent) to truncate a shared temp file between |
| - // this call's hash and its rename, which would publish the other writer's |
| - // partial bytes under a passing hash. |
| - out, err := os.CreateTemp(filepath.Dir(dst), filepath.Base(dst)+".tmp-*") |
| - if err != nil { |
| - return err |
| - } |
| - tmp := out.Name() |
| - h := sha256.New() |
| - if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil { |
| - out.Close() |
| - os.Remove(tmp) |
| - return fmt.Errorf("download %s: %w", ver, err) |
| - } |
| - if err := out.Close(); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if hex.EncodeToString(h.Sum(nil)) != wantSHA { |
| - os.Remove(tmp) |
| - return fmt.Errorf("download %s: %w (refusing to stage)", ver, errArtifactMismatch) |
| - } |
| - // CreateTemp makes the file 0600; the binary must be 0755 to exec. |
| - if err := os.Chmod(tmp, 0o755); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := os.Rename(tmp, dst); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - return nil |
| + return safefile.Write(dst, 0o755, func(out io.Writer) error { |
| + h := sha256.New() |
| + if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil { |
| + return fmt.Errorf("download %s: %w", ver, err) |
| + } |
| + if hex.EncodeToString(h.Sum(nil)) != wantSHA { |
| + return fmt.Errorf("download %s: %w (refusing to stage)", ver, errArtifactMismatch) |
| + } |
| + return nil |
| + }) |
| } |
| // stagePath validates the names, makes the version dir traversable, and |
| @@ -212,7 +154,7 @@ func stagePath(optDir, acct, ver, app string) (string, error) { |
| } |
| dst := VersionPath(optDir, acct, ver, app) |
| dir := filepath.Dir(dst) |
| - if err := os.MkdirAll(dir, 0o755); err != nil { |
| + if err := safefile.MkdirAll(dir, 0o755); err != nil { |
| return "", err |
| } |
| // MkdirAll's mode is masked by the caller's umask, so a tight operator |
| @@ -277,7 +219,7 @@ func StageManifest(optDir, acct, ver, app string, raw []byte) error { |
| dir := filepath.Dir(StagedManifestPath(optDir, acct, ver, app)) |
| _, statErr := os.Stat(dir) |
| dirExisted := statErr == nil |
| - if err := os.MkdirAll(dir, 0o755); err != nil { |
| + if err := safefile.MkdirAll(dir, 0o755); err != nil { |
| return err |
| } |
| if !dirExisted { |
| @@ -317,7 +259,7 @@ func StageManifest(optDir, acct, ver, app string, raw []byte) error { |
| // (CanonicalDemand); this sibling exists so the converge pass can read a |
| // running version's capabilities without a bucket round trip on every tick. |
| func ReadStagedManifest(optDir, acct, ver, app string) (*Manifest, error) { |
| - raw, err := os.ReadFile(StagedManifestPath(optDir, acct, ver, app)) |
| + raw, err := safefile.ReadFile(StagedManifestPath(optDir, acct, ver, app)) |
| if err != nil { |
| if os.IsNotExist(err) { |
| return nil, nil |
| @@ -345,7 +287,7 @@ func PointLive(optDir, host, acct, ver, app string) error { |
| if _, err := os.Stat(target); err != nil { |
| return fmt.Errorf("version %q of %s is not staged (%s)", ver, app, target) |
| } |
| - if err := os.MkdirAll(liveDir(optDir), 0o755); err != nil { |
| + if err := safefile.MkdirAll(liveDir(optDir), 0o755); err != nil { |
| return err |
| } |
| link := LiveLink(optDir, host) |
diff --git a/internal/safefile/file.go b/internal/safefile/file.go| new file mode 100644 |
| index 000000000..63e72a6ab |
| --- /dev/null |
| +++ b/internal/safefile/file.go |
| @@ -0,0 +1,19 @@ |
| +package safefile |
| + |
| +import ( |
| + "io" |
| + "os" |
| +) |
| + |
| +func WriteFile(path string, body []byte, mode os.FileMode) error { |
| + return Write(path, mode, func(w io.Writer) error { _, err := w.Write(body); return err }) |
| +} |
| + |
| +func ReadFile(path string) ([]byte, error) { |
| + f, err := OpenFile(path, os.O_RDONLY, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer f.Close() |
| + return io.ReadAll(f) |
| +} |
diff --git a/internal/safefile/file_linux.go b/internal/safefile/file_linux.go| new file mode 100644 |
| index 000000000..ce729d0ef |
| --- /dev/null |
| +++ b/internal/safefile/file_linux.go |
| @@ -0,0 +1,195 @@ |
| +package safefile |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/hex" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func openDir(path string, create bool, mode os.FileMode) (int, error) { |
| + abs, err := filepath.Abs(path) |
| + if err != nil { |
| + return -1, err |
| + } |
| + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return -1, err |
| + } |
| + for _, name := range strings.Split(strings.TrimPrefix(abs, "/"), "/") { |
| + if name == "" { |
| + continue |
| + } |
| + next, e := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if e == unix.ENOENT && create { |
| + e = unix.Mkdirat(fd, name, uint32(mode.Perm())) |
| + if e == nil || e == unix.EEXIST { |
| + next, e = unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + } |
| + } |
| + unix.Close(fd) |
| + if e != nil { |
| + return -1, &os.PathError{Op: "open directory", Path: path, Err: e} |
| + } |
| + fd = next |
| + } |
| + return fd, nil |
| +} |
| + |
| +func MkdirAll(path string, mode os.FileMode) error { |
| + fd, err := openDir(path, true, mode) |
| + if err == nil { |
| + err = unix.Close(fd) |
| + } |
| + return err |
| +} |
| + |
| +func OpenFile(path string, flags int, mode os.FileMode) (*os.File, error) { |
| + dir, err := openDir(filepath.Dir(path), false, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer unix.Close(dir) |
| + return OpenFileAt(dir, filepath.Base(path), flags, mode) |
| +} |
| + |
| +func OpenFileAt(dir int, path string, flags int, mode os.FileMode) (*os.File, error) { |
| + if path != filepath.Base(path) || path == "." || path == ".." { |
| + return nil, fmt.Errorf("invalid file name") |
| + } |
| + var err error |
| + fd := -1 |
| + if flags&os.O_CREATE != 0 { |
| + fd, err = unix.Openat(dir, filepath.Base(path), (flags&^os.O_TRUNC)|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, uint32(mode.Perm())) |
| + if err != nil && (err != unix.EEXIST || flags&os.O_EXCL != 0) { |
| + return nil, &os.PathError{Op: "open", Path: path, Err: err} |
| + } |
| + } |
| + if fd < 0 { |
| + pinned, e := unix.Openat(dir, filepath.Base(path), unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return nil, &os.PathError{Op: "open", Path: path, Err: e} |
| + } |
| + defer unix.Close(pinned) |
| + var st unix.Stat_t |
| + if e = unix.Fstat(pinned, &st); e != nil { |
| + return nil, e |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| + return nil, fmt.Errorf("%s: not a regular file with one link", path) |
| + } |
| + fd, err = unix.Open(fmt.Sprintf("/proc/self/fd/%d", pinned), (flags&^(os.O_CREATE|os.O_EXCL|os.O_TRUNC))|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, &os.PathError{Op: "open", Path: path, Err: err} |
| + } |
| + } |
| + |
| + f := os.NewFile(uintptr(fd), path) |
| + var st unix.Stat_t |
| + if err = unix.Fstat(fd, &st); err == nil && (st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1) { |
| + err = fmt.Errorf("not a regular file with one link") |
| + } |
| + if err == nil && flags&os.O_TRUNC != 0 { |
| + err = f.Truncate(0) |
| + } |
| + if err != nil { |
| + f.Close() |
| + return nil, &os.PathError{Op: "open", Path: path, Err: err} |
| + } |
| + return f, nil |
| +} |
| + |
| +func Remove(path string) error { |
| + dir, err := openDir(filepath.Dir(path), false, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(dir) |
| + err = unix.Unlinkat(dir, filepath.Base(path), 0) |
| + if err != nil { |
| + return &os.PathError{Op: "remove", Path: path, Err: err} |
| + } |
| + return nil |
| +} |
| + |
| +func Write(path string, mode os.FileMode, write func(io.Writer) error) error { |
| + dir, err := openDir(filepath.Dir(path), false, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(dir) |
| + return writeAt(dir, filepath.Base(path), mode, write) |
| +} |
| + |
| +func WriteAt(dir int, name string, body []byte, mode os.FileMode) error { |
| + return writeAt(dir, name, mode, func(w io.Writer) error { _, err := w.Write(body); return err }) |
| +} |
| + |
| +func OpenDir(path string) (*os.File, error) { |
| + fd, err := openDir(path, false, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return os.NewFile(uintptr(fd), path), nil |
| +} |
| + |
| +func writeAt(dir int, path string, mode os.FileMode, write func(io.Writer) error) error { |
| + if path != filepath.Base(path) || path == "." || path == ".." { |
| + return fmt.Errorf("invalid file name") |
| + } |
| + var nonce [16]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| + return err |
| + } |
| + name := ".carlos-write-" + hex.EncodeToString(nonce[:]) |
| + if err := unix.Mkdirat(dir, name, 0o700); err != nil { |
| + return err |
| + } |
| + defer unix.Unlinkat(dir, name, unix.AT_REMOVEDIR) |
| + stage, err := unix.Openat(dir, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(stage) |
| + var st unix.Stat_t |
| + if err := unix.Fstat(stage, &st); err != nil { |
| + return err |
| + } |
| + if st.Uid != uint32(os.Geteuid()) || st.Mode&0o077 != 0 { |
| + return fmt.Errorf("unsafe staging directory") |
| + } |
| + fd, err := unix.Openat(stage, "data", unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Unlinkat(stage, "data", 0) |
| + f := os.NewFile(uintptr(fd), path) |
| + defer f.Close() |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + return err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| + return fmt.Errorf("not a regular file with one link") |
| + } |
| + if err := write(f); err != nil { |
| + return err |
| + } |
| + if err := f.Chmod(mode); err != nil { |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + if err := unix.Renameat(stage, "data", dir, filepath.Base(path)); err != nil { |
| + return err |
| + } |
| + return unix.Fsync(dir) |
| +} |
diff --git a/internal/safefile/file_linux_test.go b/internal/safefile/file_linux_test.go| new file mode 100644 |
| index 000000000..de030c8e7 |
| --- /dev/null |
| +++ b/internal/safefile/file_linux_test.go |
| @@ -0,0 +1,102 @@ |
| +package safefile |
| + |
| +import ( |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestRefuseUnsafeOpen(t *testing.T) { |
| + for _, kind := range []string{"symlink", "hardlink", "fifo", "parent"} { |
| + t.Run(kind, func(t *testing.T) { |
| + dir := t.TempDir() |
| + victim := filepath.Join(dir, "victim") |
| + if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "unsafe") |
| + var err error |
| + switch kind { |
| + case "symlink": |
| + err = os.Symlink(victim, path) |
| + case "hardlink": |
| + err = os.Link(victim, path) |
| + case "fifo": |
| + err = unix.Mkfifo(path, 0o600) |
| + case "parent": |
| + err = os.Symlink(dir, path) |
| + path = filepath.Join(path, "victim") |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644) |
| + if err == nil { |
| + f.Close() |
| + t.Fatal("unsafe path opened") |
| + } |
| + got, err := os.ReadFile(victim) |
| + if err != nil || string(got) != "untouched" { |
| + t.Fatalf("victim changed: %q %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestWritePinsParentAndTemporaryFile(t *testing.T) { |
| + dir := t.TempDir() |
| + parent := filepath.Join(dir, "state") |
| + outside := filepath.Join(dir, "outside") |
| + for _, p := range []string{parent, outside} { |
| + if err := os.Mkdir(p, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + victim := filepath.Join(outside, "data") |
| + if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + moved := filepath.Join(dir, "moved") |
| + err := Write(filepath.Join(parent, "result"), 0o644, func(w io.Writer) error { |
| + entries, err := os.ReadDir(parent) |
| + if err != nil { |
| + return err |
| + } |
| + stage := filepath.Join(parent, entries[0].Name()) |
| + if err := os.Rename(stage, stage+"-moved"); err != nil { |
| + return err |
| + } |
| + if err := os.Symlink(outside, stage); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(parent, moved); err != nil { |
| + return err |
| + } |
| + if err := os.Symlink(outside, parent); err != nil { |
| + return err |
| + } |
| + _, err = io.WriteString(w, "safe") |
| + return err |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := os.ReadFile(filepath.Join(moved, "result")) |
| + if err != nil || string(got) != "safe" { |
| + t.Fatalf("anchored result: %q %v", got, err) |
| + } |
| + got, err = os.ReadFile(victim) |
| + if err != nil || string(got) != "untouched" { |
| + t.Fatalf("victim changed: %q %v", got, err) |
| + } |
| + st, err := os.Stat(victim) |
| + if err != nil || st.Mode().Perm() != 0o600 { |
| + t.Fatalf("victim permissions changed: %v %v", st, err) |
| + } |
| + if _, err := os.Stat(filepath.Join(outside, "result")); !os.IsNotExist(err) { |
| + t.Fatalf("published outside pinned parent: %v", err) |
| + } |
| +} |
diff --git a/internal/safefile/file_other.go b/internal/safefile/file_other.go| new file mode 100644 |
| index 000000000..3bda377f5 |
| --- /dev/null |
| +++ b/internal/safefile/file_other.go |
| @@ -0,0 +1,61 @@ |
| +//go:build !linux |
| + |
| +package safefile |
| + |
| +import ( |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| +) |
| + |
| +func MkdirAll(path string, mode os.FileMode) error { return os.MkdirAll(path, mode) } |
| +func OpenFile(path string, flags int, mode os.FileMode) (*os.File, error) { |
| + return os.OpenFile(path, flags, mode) |
| +} |
| +func Remove(path string) error { return os.Remove(path) } |
| +func Write(path string, mode os.FileMode, write func(io.Writer) error) error { |
| + f, err := os.CreateTemp(filepath.Dir(path), ".carlos-write-*") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(f.Name()) |
| + defer f.Close() |
| + if err := write(f); err != nil { |
| + return err |
| + } |
| + if err := f.Chmod(mode); err != nil { |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + return os.Rename(f.Name(), path) |
| +} |
| + |
| +func OpenDir(path string) (*os.File, error) { |
| + st, err := os.Lstat(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !st.IsDir() { |
| + return nil, fmt.Errorf("%s is not a directory", path) |
| + } |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + st, err = f.Stat() |
| + if err != nil { |
| + f.Close() |
| + return nil, err |
| + } |
| + if !st.IsDir() { |
| + f.Close() |
| + return nil, fmt.Errorf("%s is not a directory", path) |
| + } |
| + return f, nil |
| +} |