package profile import ( "encoding/json" "errors" "fmt" "io" "io/fs" "os" "os/user" "path/filepath" "strconv" "strings" "golang.org/x/sys/unix" "proxyctl/internal/pathutil" "proxyctl/internal/securefs" ) type FileStore struct { path string owner *UserIdentity trustedRoot string operations fileOperations } type fileOperations interface { SetOwner(*os.File, int, int) error SyncDirectory(*os.File) error } type systemFileOperations struct{} func (systemFileOperations) SetOwner(file *os.File, uid, gid int) error { return file.Chown(uid, gid) } func (systemFileOperations) SyncDirectory(directory *os.File) error { return directory.Sync() } func NewFileStore(path string) *FileStore { return &FileStore{path: path, trustedRoot: securefs.ExistingTrustedRoot(path), operations: systemFileOperations{}} } func NewOwnedFileStore(path string, owner UserIdentity) *FileStore { return NewOwnedFileStoreAt(path, owner, owner.HomeDir) } func NewOwnedFileStoreAt(path string, owner UserIdentity, trustedRoot string) *FileStore { return &FileStore{path: path, owner: &owner, trustedRoot: trustedRoot, operations: systemFileOperations{}} } func newFileStore(path string, owner *UserIdentity, operations fileOperations) *FileStore { trustedRoot := securefs.ExistingTrustedRoot(path) if owner != nil { trustedRoot = owner.HomeDir } return &FileStore{path: path, owner: owner, trustedRoot: trustedRoot, operations: operations} } func (s *FileStore) secureStore() securefs.Store { store := securefs.Store{TrustedRoot: s.trustedRoot} if s.owner != nil { store.Owner = &securefs.Owner{UID: s.owner.UID, GID: s.owner.GID} } return store } type UserIdentity struct { Username string HomeDir string UID int GID int } type Configuration struct { Path string Owner UserIdentity TrustedRoot string } type IdentityEnvironment interface { CurrentUser() (UserIdentity, error) LookupUser(string) (UserIdentity, error) LookupEnv(string) (string, bool) } type systemIdentityEnvironment struct{} func identityFromUser(value *user.User) (UserIdentity, error) { uid, err := strconv.Atoi(value.Uid) if err != nil { return UserIdentity{}, fmt.Errorf("parse uid %q: %w", value.Uid, err) } gid, err := strconv.Atoi(value.Gid) if err != nil { return UserIdentity{}, fmt.Errorf("parse gid %q: %w", value.Gid, err) } return UserIdentity{Username: value.Username, HomeDir: value.HomeDir, UID: uid, GID: gid}, nil } func (systemIdentityEnvironment) CurrentUser() (UserIdentity, error) { value, err := user.Current() if err != nil { return UserIdentity{}, err } return identityFromUser(value) } func (systemIdentityEnvironment) LookupUser(name string) (UserIdentity, error) { value, err := user.Lookup(name) if err != nil { return UserIdentity{}, err } return identityFromUser(value) } func (systemIdentityEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } // SystemCurrentUser and SystemLookupUser expose the same injectable identity model // to user-scoped Target configuration without duplicating OS account parsing. func SystemCurrentUser() (UserIdentity, error) { return systemIdentityEnvironment{}.CurrentUser() } func SystemLookupUser(name string) (UserIdentity, error) { return systemIdentityEnvironment{}.LookupUser(name) } func ConfigPath() (string, error) { configuration, err := ResolveConfigurationForCurrentProcess() return configuration.Path, err } func ResolveConfigurationForCurrentProcess() (Configuration, error) { return ResolveConfiguration(systemIdentityEnvironment{}) } func ResolveConfiguration(environment IdentityEnvironment) (Configuration, error) { owner, err := environment.CurrentUser() if err != nil { return Configuration{}, 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 Configuration{}, fmt.Errorf("identify invoking user %q: %w", name, err) } invokingUser = true } if owner.HomeDir == "" { return Configuration{}, errors.New("configuration owner has no home directory") } if owner.UID < 0 || owner.GID < 0 { return Configuration{}, errors.New("configuration owner has an invalid uid or gid") } configDir := "" trustedRoot := owner.HomeDir if value, ok := environment.LookupEnv("XDG_CONFIG_HOME"); ok && filepath.IsAbs(value) { if !invokingUser || pathWithin(value, owner.HomeDir) { configDir = value if !pathWithin(value, owner.HomeDir) { trustedRoot = value } } } if configDir == "" { configDir = filepath.Join(owner.HomeDir, ".config") } return Configuration{ Path: filepath.Join(configDir, "proxyctl", "profiles.json"), Owner: owner, TrustedRoot: trustedRoot, }, nil } func ConfigPathWith(environment IdentityEnvironment) (string, error) { configuration, err := ResolveConfiguration(environment) return configuration.Path, err } func pathWithin(path, parent string) bool { return pathutil.Within(path, parent) } type CommitError struct { Err error } func (e *CommitError) Error() string { return fmt.Sprintf("Profile configuration was committed but durability could not be confirmed: %v", e.Err) } func (e *CommitError) Unwrap() error { return e.Err } func (s *FileStore) Load() (State, error) { return s.load() } func (s *FileStore) Mutate(change func(State) (State, error)) (State, error) { directory, err := s.openConfigurationDirectory(true) if err != nil { return State{}, err } defer directory.Close() lock, err := s.openLockAt(directory) if err != nil { return State{}, err } defer lock.Close() if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { return State{}, fmt.Errorf("lock Profile configuration: %w", err) } defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }() current, err := s.loadFrom(directory) if err != nil { return State{}, err } next, err := change(current) if err != nil { return current, err } committed, err := s.save(next, directory) if committed { return next, err } if err != nil { return current, err } return next, nil } func openNoFollow(path string, flags int, mode uint32) (*os.File, error) { return securefs.OpenNoFollow(path, flags, mode) } func openAt(directory *os.File, name string, flags int, mode uint32) (*os.File, error) { return securefs.OpenAt(directory, name, flags, mode) } func (s *FileStore) load() (State, error) { directory, err := s.openConfigurationDirectory(false) if errors.Is(err, fs.ErrNotExist) { return State{}, nil } if err != nil { return State{}, err } defer directory.Close() if err := s.secureExistingLock(directory); err != nil { return State{}, err } return s.loadFrom(directory) } func (s *FileStore) loadFrom(directory *os.File) (State, error) { file, err := openAt(directory, filepath.Base(s.path), unix.O_RDONLY|unix.O_NONBLOCK, 0) if errors.Is(err, fs.ErrNotExist) { return State{}, nil } if err != nil { return State{}, fmt.Errorf("open Profile configuration without following links: %w", err) } defer file.Close() if err := s.secureRegularFile(file, "Profile configuration"); err != nil { return State{}, 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) } return state, nil } func (s *FileStore) secureRegularFile(file *os.File, description string) error { info, err := file.Stat() if err != nil { return fmt.Errorf("inspect %s: %w", description, err) } if !info.Mode().IsRegular() { return fmt.Errorf("%s is not a regular file", description) } if s.owner != nil { if err := s.operations.SetOwner(file, s.owner.UID, s.owner.GID); err != nil { return fmt.Errorf("set %s owner: %w", description, err) } } if info.Mode().Perm() != 0o600 { if err := file.Chmod(0o600); err != nil { return fmt.Errorf("secure %s: %w", description, err) } } return nil } func (s *FileStore) openConfigurationDirectory(create bool) (*os.File, error) { directoryPath := filepath.Clean(filepath.Dir(s.path)) if !filepath.IsAbs(directoryPath) { return nil, errors.New("Profile configuration path must be absolute") } if directoryPath == string(filepath.Separator) { return nil, errors.New("Profile configuration directory cannot be the filesystem root") } creationRoot := directoryPath if s.owner != nil { creationRoot = filepath.Clean(s.trustedRoot) if !filepath.IsAbs(creationRoot) || !pathWithin(directoryPath, creationRoot) || directoryPath == creationRoot { return nil, errors.New("Profile configuration directory escapes its trusted root") } } current, err := openNoFollow(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY, 0) if err != nil { return nil, fmt.Errorf("open filesystem root: %w", err) } currentPath := string(filepath.Separator) for _, component := range strings.Split(strings.TrimPrefix(directoryPath, string(filepath.Separator)), string(filepath.Separator)) { if component == "" { continue } nextPath := filepath.Join(currentPath, component) next, openErr := openAt(current, component, unix.O_RDONLY|unix.O_DIRECTORY, 0) if errors.Is(openErr, fs.ErrNotExist) && create && s.mayCreateDirectory(nextPath, directoryPath, creationRoot) { created := false if mkdirErr := unix.Mkdirat(int(current.Fd()), component, 0o700); mkdirErr != nil { if !errors.Is(mkdirErr, fs.ErrExist) { current.Close() return nil, fmt.Errorf("create Profile configuration directory component %q: %w", nextPath, mkdirErr) } } else { created = true } next, openErr = openAt(current, component, unix.O_RDONLY|unix.O_DIRECTORY, 0) if openErr == nil && created { if err := s.secureCreatedDirectory(next, nextPath); err != nil { next.Close() _ = unix.Unlinkat(int(current.Fd()), component, unix.AT_REMOVEDIR) current.Close() return nil, err } } } current.Close() if openErr != nil { if errors.Is(openErr, fs.ErrNotExist) { return nil, openErr } return nil, fmt.Errorf("open Profile configuration ancestor %q without following links: %w", nextPath, openErr) } current = next currentPath = nextPath } if err := s.secureConfigurationDirectory(current); err != nil { current.Close() return nil, err } return current, nil } func (s *FileStore) mayCreateDirectory(path, directoryPath, creationRoot string) bool { if s.owner == nil { return path == directoryPath } return path != creationRoot && pathWithin(path, creationRoot) } func (s *FileStore) secureCreatedDirectory(directory *os.File, path string) error { if s.owner != nil { if err := s.operations.SetOwner(directory, s.owner.UID, s.owner.GID); err != nil { return fmt.Errorf("set created Profile configuration directory %q owner: %w", path, err) } } if err := directory.Chmod(0o700); err != nil { return fmt.Errorf("secure created Profile configuration directory %q: %w", path, err) } return nil } func (s *FileStore) secureConfigurationDirectory(directory *os.File) error { if s.owner != nil { if err := s.operations.SetOwner(directory, s.owner.UID, s.owner.GID); err != nil { return fmt.Errorf("set Profile configuration directory owner: %w", err) } } if err := directory.Chmod(0o700); err != nil { return fmt.Errorf("secure Profile configuration directory: %w", err) } return nil } func (s *FileStore) secureExistingLock(directory *os.File) error { lock, err := openAt(directory, filepath.Base(s.path)+".lock", unix.O_RDWR, 0) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("open existing Profile configuration lock without following links: %w", err) } defer lock.Close() return s.secureRegularFile(lock, "Profile configuration lock") } func (s *FileStore) openLockAt(directory *os.File) (*os.File, error) { lock, err := openAt(directory, filepath.Base(s.path)+".lock", unix.O_CREAT|unix.O_RDWR, 0o600) if err != nil { return nil, fmt.Errorf("open Profile configuration lock without following links: %w", err) } if err := s.secureRegularFile(lock, "Profile configuration lock"); err != nil { lock.Close() return nil, err } return lock, nil } func (s *FileStore) save(state State, directory *os.File) (bool, error) { data, err := json.MarshalIndent(state, "", " ") if err != nil { return false, fmt.Errorf("encode Profile configuration: %w", err) } data = append(data, '\n') temp, tempBase, err := createTemporaryAt(directory) if err != nil { return false, fmt.Errorf("create temporary Profile configuration: %w", err) } defer unix.Unlinkat(int(directory.Fd()), tempBase, 0) //nolint:errcheck -- rename normally removes the temporary name if s.owner != nil { if err := s.operations.SetOwner(temp, s.owner.UID, s.owner.GID); err != nil { temp.Close() return false, fmt.Errorf("set temporary Profile configuration owner: %w", err) } } if err := temp.Chmod(0o600); err != nil { temp.Close() return false, fmt.Errorf("secure temporary Profile configuration: %w", err) } if _, err := temp.Write(data); err != nil { temp.Close() return false, fmt.Errorf("write temporary Profile configuration: %w", err) } if err := temp.Sync(); err != nil { temp.Close() return false, fmt.Errorf("sync temporary Profile configuration: %w", err) } if err := temp.Close(); err != nil { return false, fmt.Errorf("close temporary Profile configuration: %w", err) } if err := unix.Renameat(int(directory.Fd()), tempBase, int(directory.Fd()), filepath.Base(s.path)); err != nil { return false, fmt.Errorf("replace Profile configuration: %w", err) } if err := s.operations.SyncDirectory(directory); err != nil { return true, &CommitError{Err: fmt.Errorf("sync Profile configuration directory: %w", err)} } return true, nil } func createTemporaryAt(directory *os.File) (*os.File, string, error) { return securefs.CreateTemporaryAt(directory, ".profiles-") }