Files
proxy/internal/profile/store_test.go
T

448 lines
15 KiB
Go

package profile
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
type ownerCall struct {
path string
uid, gid int
}
type fakeFileOperations struct {
calls []ownerCall
failContains string
syncErr error
}
func (f *fakeFileOperations) SetOwner(file *os.File, uid, gid int) error {
f.calls = append(f.calls, ownerCall{path: file.Name(), uid: uid, gid: gid})
if f.failContains != "" && strings.Contains(filepath.Base(file.Name()), f.failContains) {
return errors.New("injected ownership failure")
}
return nil
}
func (f *fakeFileOperations) SyncDirectory(*os.File) error { return f.syncErr }
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 TestOwnedStoreAssignsInvokingUserToDirectoryLockAndReplacement(t *testing.T) {
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
owner := UserIdentity{Username: "alice", HomeDir: filepath.Dir(filepath.Dir(path)), UID: 1001, GID: 1002}
operations := &fakeFileOperations{}
store := newFileStore(path, &owner, operations)
if _, err := store.Mutate(func(state State) (State, error) {
state.Profiles = append(state.Profiles, Profile{Name: "office"})
return state, nil
}); err != nil {
t.Fatal(err)
}
wantKinds := map[string]bool{"directory": false, "lock": false, "temporary replacement": false}
for _, call := range operations.calls {
if call.uid != owner.UID || call.gid != owner.GID {
t.Fatalf("ownership call for %q used %d:%d, want %d:%d", call.path, call.uid, call.gid, owner.UID, owner.GID)
}
switch {
case call.path == filepath.Dir(path):
wantKinds["directory"] = true
case call.path == path+".lock":
wantKinds["lock"] = true
case strings.HasPrefix(filepath.Base(call.path), ".profiles-"):
wantKinds["temporary replacement"] = true
}
}
for kind, seen := range wantKinds {
if !seen {
t.Errorf("no ownership assignment for %s; calls=%#v", kind, operations.calls)
}
}
}
func TestOwnedStoreReportsOwnershipFailuresBeforeCommit(t *testing.T) {
for _, failure := range []string{"proxyctl", "profiles.json.lock", ".profiles-"} {
t.Run(failure, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
owner := UserIdentity{Username: "alice", HomeDir: filepath.Dir(filepath.Dir(path)), UID: 1001, GID: 1002}
operations := &fakeFileOperations{failContains: failure}
store := newFileStore(path, &owner, operations)
_, err := store.Mutate(func(state State) (State, error) {
state.Profiles = append(state.Profiles, Profile{Name: "must-not-commit"})
return state, nil
})
if err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("ownership failure = %v", err)
}
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("configuration was committed after ownership failure: %v", statErr)
}
})
}
}
func TestOwnedLoadRejectsConfigurationOwnershipFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{"profiles":[]}`), 0o600); err != nil {
t.Fatal(err)
}
owner := UserIdentity{Username: "alice", HomeDir: filepath.Dir(filepath.Dir(path)), UID: 1001, GID: 1002}
operations := &fakeFileOperations{failContains: "profiles.json"}
_, err := newFileStore(path, &owner, operations).Load()
if err == nil || !strings.Contains(err.Error(), "set Profile configuration owner") {
t.Fatalf("configuration ownership failure = %v", err)
}
}
func TestLoadRejectsProfileConfigurationSymlinkWithoutChangingTarget(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "target.json")
if err := os.WriteFile(target, []byte(`{"profiles":[]}`), 0o644); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, "profiles.json")
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
_, err := NewFileStore(path).Load()
if err == nil || !strings.Contains(err.Error(), "without following links") {
t.Fatalf("symlink load error = %v", err)
}
info, statErr := os.Stat(target)
if statErr != nil {
t.Fatal(statErr)
}
if info.Mode().Perm() != 0o644 {
t.Fatalf("symlink target mode changed to %#o", info.Mode().Perm())
}
}
func TestLoadRejectsLockSymlinkWithoutChangingTarget(t *testing.T) {
directory := filepath.Join(t.TempDir(), "proxyctl")
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "lock-target")
if err := os.WriteFile(target, []byte("unchanged"), 0o644); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, "profiles.json")
if err := os.Symlink(target, path+".lock"); err != nil {
t.Fatal(err)
}
_, err := NewFileStore(path).Load()
if err == nil || !strings.Contains(err.Error(), "without following links") {
t.Fatalf("lock symlink error = %v", err)
}
contents, readErr := os.ReadFile(target)
if readErr != nil {
t.Fatal(readErr)
}
if string(contents) != "unchanged" {
t.Fatalf("lock symlink target was modified: %q", contents)
}
info, statErr := os.Stat(target)
if statErr != nil {
t.Fatal(statErr)
}
if info.Mode().Perm() != 0o644 {
t.Fatalf("lock symlink target mode changed to %#o", info.Mode().Perm())
}
}
func TestLoadRepairsExistingDirectoryConfigurationAndLockOwnership(t *testing.T) {
home := t.TempDir()
directory := filepath.Join(home, ".config", "proxyctl")
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(directory, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, "profiles.json")
if err := os.WriteFile(path, []byte(`{"profiles":[]}`), 0o644); err != nil {
t.Fatal(err)
}
lockPath := path + ".lock"
if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
t.Fatal(err)
}
owner := UserIdentity{Username: "alice", HomeDir: home, UID: 1001, GID: 1002}
operations := &fakeFileOperations{}
_, err := newFileStore(path, &owner, operations).Load()
if err != nil {
t.Fatal(err)
}
for target, mode := range map[string]os.FileMode{directory: 0o700, path: 0o600, lockPath: 0o600} {
info, statErr := os.Stat(target)
if statErr != nil {
t.Fatal(statErr)
}
if info.Mode().Perm() != mode {
t.Errorf("mode for %s = %#o, want %#o", target, info.Mode().Perm(), mode)
}
}
for _, target := range []string{directory, path, lockPath} {
found := false
for _, call := range operations.calls {
if call.path == target && call.uid == 1001 && call.gid == 1002 {
found = true
}
}
if !found {
t.Errorf("no invoking-user ownership repair for %s; calls=%#v", target, operations.calls)
}
}
}
func TestLoadRejectsConfigAncestorSymlinkWithoutChangingVictim(t *testing.T) {
home := t.TempDir()
victim := t.TempDir()
victimFile := filepath.Join(victim, "unchanged")
if err := os.WriteFile(victimFile, []byte("victim"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(victim, filepath.Join(home, ".config")); err != nil {
t.Fatal(err)
}
path := filepath.Join(home, ".config", "proxyctl", "profiles.json")
owner := UserIdentity{Username: "alice", HomeDir: home, UID: 1001, GID: 1002}
_, err := newFileStore(path, &owner, &fakeFileOperations{}).Load()
if err == nil || !strings.Contains(err.Error(), "without following links") {
t.Fatalf("ancestor symlink error = %v", err)
}
contents, readErr := os.ReadFile(victimFile)
if readErr != nil || string(contents) != "victim" {
t.Fatalf("victim changed: contents=%q error=%v", contents, readErr)
}
if _, statErr := os.Stat(filepath.Join(victim, "proxyctl")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("created through ancestor symlink: %v", statErr)
}
}
func TestMutationRejectsIntermediateXDGAncestorSymlink(t *testing.T) {
home := t.TempDir()
xdg := filepath.Join(home, "xdg")
if err := os.Mkdir(xdg, 0o700); err != nil {
t.Fatal(err)
}
victim := t.TempDir()
if err := os.Symlink(victim, filepath.Join(xdg, "intermediate")); err != nil {
t.Fatal(err)
}
path := filepath.Join(xdg, "intermediate", "proxyctl", "profiles.json")
owner := UserIdentity{Username: "alice", HomeDir: home, UID: 1001, GID: 1002}
store := &FileStore{path: path, owner: &owner, trustedRoot: xdg, operations: &fakeFileOperations{}}
_, err := store.Mutate(func(state State) (State, error) { return state, nil })
if err == nil || !strings.Contains(err.Error(), "without following links") {
t.Fatalf("intermediate symlink error = %v", err)
}
entries, readErr := os.ReadDir(victim)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("victim directory changed through symlink: %#v", entries)
}
}
func TestOwnedMutationCreatesMissingDirectoriesAnchoredWithOwnerAndMode(t *testing.T) {
home := t.TempDir()
path := filepath.Join(home, ".config", "nested", "proxyctl", "profiles.json")
owner := UserIdentity{Username: "alice", HomeDir: home, UID: 1001, GID: 1002}
operations := &fakeFileOperations{}
store := newFileStore(path, &owner, operations)
if _, err := store.Mutate(func(state State) (State, error) { return state, nil }); err != nil {
t.Fatal(err)
}
for _, directory := range []string{
filepath.Join(home, ".config"),
filepath.Join(home, ".config", "nested"),
filepath.Join(home, ".config", "nested", "proxyctl"),
} {
info, err := os.Stat(directory)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o700 {
t.Errorf("created directory %s mode = %#o, want 0700", directory, info.Mode().Perm())
}
found := false
for _, call := range operations.calls {
if call.path == directory && call.uid == 1001 && call.gid == 1002 {
found = true
}
}
if !found {
t.Errorf("created directory %s was not assigned invoking-user ownership", directory)
}
}
}
func TestDirectorySyncFailureReturnsCommittedStateAndRecognizableError(t *testing.T) {
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
operations := &fakeFileOperations{syncErr: errors.New("injected sync failure")}
store := newFileStore(path, nil, operations)
next, err := store.Mutate(func(state State) (State, error) {
state.Profiles = append(state.Profiles, Profile{Name: "committed"})
return state, nil
})
var committed *CommitError
if !errors.As(err, &committed) {
t.Fatalf("sync failure = %v, want CommitError", err)
}
if len(next.Profiles) != 1 || next.Profiles[0].Name != "committed" {
t.Fatalf("returned committed state = %#v", next)
}
loaded, loadErr := NewFileStore(path).Load()
if loadErr != nil {
t.Fatal(loadErr)
}
if fmt.Sprint(loaded) != fmt.Sprint(next) {
t.Fatalf("disk state = %#v, committed state = %#v", loaded, next)
}
}
func TestConfigPathUsesInvokingUserIdentityUnderSudo(t *testing.T) {
environment := fakeIdentityEnvironment{
current: UserIdentity{Username: "root", HomeDir: "/root"},
users: map[string]UserIdentity{"alice": {Username: "alice", HomeDir: "/home/alice", UID: 1001, GID: 1002}},
env: map[string]string{
"SUDO_USER": "alice",
"XDG_CONFIG_HOME": "/home/alice/.xdg/config",
},
}
configuration, err := ResolveConfiguration(environment)
if err != nil {
t.Fatal(err)
}
want := filepath.Join("/home/alice/.xdg/config", "proxyctl", "profiles.json")
if configuration.Path != want {
t.Fatalf("configuration path = %q, want invoking user's XDG path %q", configuration.Path, want)
}
if configuration.Owner.Username != "alice" || configuration.Owner.UID != 1001 || configuration.Owner.GID != 1002 {
t.Fatalf("configuration owner = %#v, want verified invoking-user identity", configuration.Owner)
}
}
func TestConfigPathDoesNotReuseRootXDGForInvokingUser(t *testing.T) {
environment := fakeIdentityEnvironment{
current: UserIdentity{Username: "root", HomeDir: "/root"},
users: map[string]UserIdentity{"alice": {Username: "alice", HomeDir: "/home/alice", UID: 1001, GID: 1002}},
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)
}
}