carlos / platform Public

Merge remote-tracking branch 'origin/main' into moves-phase1-3c

claude agent, operated by paul@keymail.dev c8597c01173f714f38ba9412f0520339618625c1 parent c846354parent 2d3ff96
62 files changed, +1601 −617
  • internal/host/adoptlock.go +3 −1
  • internal/host/claimroleassembly_linux.go +11 −10
  • internal/host/claimroleassembly_linux_test.go +30 −0
  • internal/host/claimstate_linux.go +15 −10
  • internal/host/claimstate_linux_test.go +42 −0
  • internal/host/claimstop_registry_test.go +34 −0
  • internal/host/claimstop_run.go +26 −3
  • internal/host/movesfloor_linux.go +16 −51
  • internal/host/movesfloor_linux_test.go +61 −0
  • internal/host/proofroot.go +4 −3
  • internal/host/registry_privilege_test.go +29 −6
  • internal/host/registrychild_exec_linux.go +34 −0
  • internal/host/registrychild_exec_linux_test.go +171 −0
  • internal/host/registrychild_exec_other.go +18 −0
  • internal/host/registrychild_other.go +4 −0
  • internal/host/registrychild_unix.go +39 −10
  • internal/host/registrychild_unix_test.go +11 −8
  • internal/host/registryreadonly.go +28 −0
  • internal/host/restart.go +5 −27
  • internal/host/systemapply.go +2 −4
  • internal/host/systemapply_test.go +7 −2
  • internal/host/systemapply_unix.go +9 −22
  • internal/host/systemapply_unix_test.go +41 −0
  • internal/host/systemstate.go +7 −34
  • internal/hostfloor/controllers_linux.go +5 −2
  • internal/hostfloor/controllers_linux_test.go +35 −0
  • internal/hostfloor/participation.go +6 −6
  • internal/instancectl/claim.go +3 −1
  • internal/instancectl/server.go +7 −3
  • internal/instancectl/stagedexe.go +0 −44
  • internal/instancectl/stagedexe_test.go +5 −61
  • internal/provision/plan9_b8_census_test.go +2 −2
  • internal/registry/readonly.go +26 −0
  • internal/registry/readonly_test.go +66 −0
  • internal/registry/registry.go +2 −0
  • internal/registry/remote.go +99 −20
  • internal/registry/remote_test.go +76 −14
  • internal/release/adopt.go +2 −1
  • internal/release/adopt_test.go +5 −5
  • internal/release/bake.go +4 −25
  • internal/release/bake_test.go +2 −2
  • internal/release/conditional_delete.go +3 −2
  • internal/release/fetch.go +14 −31
  • internal/release/generations_publish_test.go +1 −1
  • internal/release/healthy.go +5 −20
  • internal/release/materializeheld.go +29 −10
  • internal/release/materializeheld_test.go +37 −0
  • internal/release/pins.go +7 −26
  • internal/release/pins_test.go +2 −2
  • internal/release/programhistory.go +5 −8
  • internal/release/proofpolicy.go +3 −1
  • internal/release/ratchet.go +3 −1
  • internal/release/refusal.go +6 −20
  • internal/release/stage_reuse_test.go +10 −5
  • internal/release/stage_stream_test.go +2 −2
  • internal/release/state_security_test.go +60 −0
  • internal/release/store.go +27 −35
  • internal/release/versions.go +18 −76
  • internal/safefile/file.go +19 −0
  • internal/safefile/file_linux.go +195 −0
  • internal/safefile/file_linux_test.go +102 −0
  • internal/safefile/file_other.go +61 −0

CI passed — 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/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index eb73640bf..eea2ae56c 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -853,8 +853,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/release/store.go", "prefixStore.PutFrom", "record-transport", "def:PutFrom,id:PutObjectFrom", "965ebec4c3349c7c103aab0687f1e08b11c624a13a9cd379658ca7185b3dc5d5"},
{"internal/release/store.go", "type PutStreamer", "reference", "id:PutFrom", "1e9ac4fc57072be0ed7e8d16b29f3bc588cb1462157bb352861bf072aa81e133"},
{"internal/release/store.go", "PutObjectFrom", "record-transport", "def:PutObjectFrom,sel:Put,sel:PutFrom", "30dd21f424804c0252b9303a9a96393042651d6fa53ece25ba6cc1af9feff00c"},
- {"internal/release/store.go", "DirStore.Put", "record-transport", "def:Put", "0e834f8b1a78979ef0611b72bc98b24b528acd56809fd1976f2e0b6a0c2ed567"},
- {"internal/release/store.go", "DirStore.PutFrom", "record-transport", "def:PutFrom", "1ddfce76d4f2110505a39916d7bc9855e5fcb51a584c531b36bbc1b65d598dd1"},
+ {"internal/release/store.go", "DirStore.Put", "record-transport", "def:Put", "cbf55e314641c00cc0dd0b8afd2d3ef975958065d3574247b5c7b294d3152e10"},
+ {"internal/release/store.go", "DirStore.PutFrom", "record-transport", "def:PutFrom", "83c432216ce25b6966d0a51be28f1f2fb1a11624ec1abfaf196d612ffb09047b"},
{"internal/release/store.go", "S3Store.Put", "record-transport", "def:Put", "43f9b7beec0d1ef16645473a574082a8fce1526d809536f7b7a48253d3c7b6b0"},
{"internal/release/store.go", "S3Store.PutFrom", "record-transport", "def:PutFrom", "46f0fc7ca6ce297a121386c78ba9ae2662687253c08a6cda490a63b9054abdce"},
{"internal/release/system.go", "PutAntivirus", "nonrecord", "sel:Put", "5fac12bfabd74baf7a5a9fc14583a346ccdaa7c6b4acc843365b99bf22e0d47d"},
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 e5f4a9ee7..f83673ad5 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
@@ -492,17 +494,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") {
@@ -516,11 +524,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
}
@@ -528,13 +536,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")
@@ -559,17 +578,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
+}