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) } }