fix: serialize persistent profile mutations

This commit is contained in:
proxyctl coordinator
2026-07-17 02:18:58 +08:00
parent f9469cf6a2
commit 80ab995e2e
7 changed files with 422 additions and 85 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ type State struct {
type Store interface {
Load() (State, error)
Save(State) error
Mutate(func(State) (State, error)) (State, error)
}
func ValidateAndNormalize(input Profile) (Profile, error) {
+136 -18
View File
@@ -4,9 +4,13 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/user"
"path/filepath"
"strings"
"syscall"
)
type FileStore struct {
@@ -17,22 +21,137 @@ func NewFileStore(path string) *FileStore {
return &FileStore{path: path}
}
func ConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
type UserIdentity struct {
Username string
HomeDir string
}
type IdentityEnvironment interface {
CurrentUser() (UserIdentity, error)
LookupUser(string) (UserIdentity, error)
LookupEnv(string) (string, bool)
}
type systemIdentityEnvironment struct{}
func (systemIdentityEnvironment) CurrentUser() (UserIdentity, error) {
value, err := user.Current()
if err != nil {
return "", fmt.Errorf("locate user configuration directory: %w", err)
return UserIdentity{}, err
}
return UserIdentity{Username: value.Username, HomeDir: value.HomeDir}, nil
}
func (systemIdentityEnvironment) LookupUser(name string) (UserIdentity, error) {
value, err := user.Lookup(name)
if err != nil {
return UserIdentity{}, err
}
return UserIdentity{Username: value.Username, HomeDir: value.HomeDir}, nil
}
func (systemIdentityEnvironment) LookupEnv(name string) (string, bool) {
return os.LookupEnv(name)
}
func ConfigPath() (string, error) {
return ConfigPathWith(systemIdentityEnvironment{})
}
func ConfigPathWith(environment IdentityEnvironment) (string, error) {
owner, err := environment.CurrentUser()
if err != nil {
return "", fmt.Errorf("identify configuration owner: %w", err)
}
invokingUser := false
if name, ok := environment.LookupEnv("SUDO_USER"); ok && name != "" && name != owner.Username {
owner, err = environment.LookupUser(name)
if err != nil {
return "", fmt.Errorf("identify invoking user %q: %w", name, err)
}
invokingUser = true
}
if owner.HomeDir == "" {
return "", errors.New("configuration owner has no home directory")
}
configDir := ""
if value, ok := environment.LookupEnv("XDG_CONFIG_HOME"); ok && filepath.IsAbs(value) {
if !invokingUser || pathWithin(value, owner.HomeDir) {
configDir = value
}
}
if configDir == "" {
configDir = filepath.Join(owner.HomeDir, ".config")
}
return filepath.Join(configDir, "proxyctl", "profiles.json"), nil
}
func pathWithin(path, parent string) bool {
relative, err := filepath.Rel(parent, path)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func (s *FileStore) Load() (State, error) {
data, err := os.ReadFile(s.path)
return s.load()
}
func (s *FileStore) Mutate(change func(State) (State, error)) (State, error) {
if err := s.secureDirectory(); err != nil {
return State{}, err
}
lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return State{}, fmt.Errorf("open Profile configuration lock: %w", err)
}
defer lock.Close()
if err := lock.Chmod(0o600); err != nil {
return State{}, fmt.Errorf("secure Profile configuration lock: %w", err)
}
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
return State{}, fmt.Errorf("lock Profile configuration: %w", err)
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck -- closing also releases the lock
current, err := s.load()
if err != nil {
return State{}, err
}
next, err := change(current)
if err != nil {
return current, err
}
if err := s.save(next); err != nil {
return current, err
}
return next, nil
}
func (s *FileStore) load() (State, error) {
file, err := os.Open(s.path)
if errors.Is(err, fs.ErrNotExist) {
return State{}, nil
}
if err != nil {
return State{}, fmt.Errorf("read Profile configuration: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return State{}, fmt.Errorf("inspect Profile configuration: %w", err)
}
if !info.Mode().IsRegular() {
return State{}, errors.New("Profile configuration is not a regular file")
}
if info.Mode().Perm() != 0o600 {
if err := file.Chmod(0o600); err != nil {
return State{}, fmt.Errorf("secure Profile configuration: %w", err)
}
}
data, err := io.ReadAll(file)
if err != nil {
return State{}, fmt.Errorf("read Profile configuration: %w", err)
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, fmt.Errorf("parse Profile configuration: %w", err)
@@ -40,13 +159,21 @@ func (s *FileStore) Load() (State, error) {
return state, nil
}
func (s *FileStore) Save(state State) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
func (s *FileStore) secureDirectory() error {
directory := filepath.Dir(s.path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("create Profile configuration directory: %w", err)
}
if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
if err := os.Chmod(directory, 0o700); err != nil {
return fmt.Errorf("secure Profile configuration directory: %w", err)
}
return nil
}
func (s *FileStore) save(state State) error {
if err := s.secureDirectory(); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode Profile configuration: %w", err)
@@ -73,19 +200,10 @@ func (s *FileStore) Save(state State) error {
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary Profile configuration: %w", err)
}
// Rename is the commit point. There are deliberately no fallible operations
// afterwards, so callers never retain stale memory after a visible commit.
if err := os.Rename(tempName, s.path); err != nil {
return fmt.Errorf("replace Profile configuration: %w", err)
}
if err := os.Chmod(s.path, 0o600); err != nil {
return fmt.Errorf("secure Profile configuration: %w", err)
}
dir, err := os.Open(filepath.Dir(s.path))
if err == nil {
err = dir.Sync()
_ = dir.Close()
}
if err != nil {
return fmt.Errorf("sync Profile configuration directory: %w", err)
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package profile
import (
"errors"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
)
type fakeIdentityEnvironment struct {
current UserIdentity
users map[string]UserIdentity
env map[string]string
}
func (f fakeIdentityEnvironment) CurrentUser() (UserIdentity, error) { return f.current, nil }
func (f fakeIdentityEnvironment) LookupUser(name string) (UserIdentity, error) {
identity, ok := f.users[name]
if !ok {
return UserIdentity{}, errors.New("not found")
}
return identity, nil
}
func (f fakeIdentityEnvironment) LookupEnv(name string) (string, bool) {
value, ok := f.env[name]
return value, ok
}
func TestFileStoreLockSerializesAcrossProcesses(t *testing.T) {
if path := os.Getenv("PROXYCTL_LOCK_HELPER_PATH"); path != "" {
store := NewFileStore(path)
_, err := store.Mutate(func(state State) (State, error) {
state.Profiles = append(state.Profiles, Profile{Name: "child"})
return state, nil
})
if err != nil {
t.Fatal(err)
}
return
}
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
store := NewFileStore(path)
if _, err := store.Mutate(func(state State) (State, error) { return state, nil }); err != nil {
t.Fatal(err)
}
lock, err := os.OpenFile(path+".lock", os.O_RDWR, 0o600)
if err != nil {
t.Fatal(err)
}
defer lock.Close()
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
t.Fatal(err)
}
command := exec.Command(os.Args[0], "-test.run=^TestFileStoreLockSerializesAcrossProcesses$")
command.Env = append(os.Environ(), "PROXYCTL_LOCK_HELPER_PATH="+path)
done := make(chan error, 1)
go func() { done <- command.Run() }()
select {
case err := <-done:
t.Fatalf("child mutation did not wait for lock: %v", err)
case <-time.After(100 * time.Millisecond):
}
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("child mutation after unlock: %v", err)
}
case <-time.After(5 * time.Second):
_ = command.Process.Kill()
t.Fatal("child mutation remained blocked after unlock")
}
state, err := store.Load()
if err != nil {
t.Fatal(err)
}
if len(state.Profiles) != 1 || state.Profiles[0].Name != "child" {
t.Fatalf("state after serialized child mutation = %#v", state)
}
}
func TestConfigPathUsesInvokingUserIdentityUnderSudo(t *testing.T) {
environment := fakeIdentityEnvironment{
current: UserIdentity{Username: "root", HomeDir: "/root"},
users: map[string]UserIdentity{"alice": {Username: "alice", HomeDir: "/home/alice"}},
env: map[string]string{
"SUDO_USER": "alice",
"XDG_CONFIG_HOME": "/home/alice/.xdg/config",
},
}
path, err := ConfigPathWith(environment)
if err != nil {
t.Fatal(err)
}
want := filepath.Join("/home/alice/.xdg/config", "proxyctl", "profiles.json")
if path != want {
t.Fatalf("configuration path = %q, want invoking user's XDG path %q", path, want)
}
}
func TestConfigPathDoesNotReuseRootXDGForInvokingUser(t *testing.T) {
environment := fakeIdentityEnvironment{
current: UserIdentity{Username: "root", HomeDir: "/root"},
users: map[string]UserIdentity{"alice": {Username: "alice", HomeDir: "/home/alice"}},
env: map[string]string{
"SUDO_USER": "alice",
"XDG_CONFIG_HOME": "/root/.config",
},
}
path, err := ConfigPathWith(environment)
if err != nil {
t.Fatal(err)
}
want := filepath.Join("/home/alice/.config", "proxyctl", "profiles.json")
if path != want {
t.Fatalf("configuration path = %q, want invoking user's fallback %q", path, want)
}
}
func TestConfigPathUsesRootForDirectRootLogin(t *testing.T) {
environment := fakeIdentityEnvironment{
current: UserIdentity{Username: "root", HomeDir: "/root"},
users: map[string]UserIdentity{},
env: map[string]string{"XDG_CONFIG_HOME": "/root/custom-config"},
}
path, err := ConfigPathWith(environment)
if err != nil {
t.Fatal(err)
}
want := filepath.Join("/root/custom-config", "proxyctl", "profiles.json")
if path != want {
t.Fatalf("configuration path = %q, want direct root path %q", path, want)
}
}