package profile import ( "encoding/json" "errors" "fmt" "io" "io/fs" "os" "os/user" "path/filepath" "strings" "syscall" ) type FileStore struct { path string } func NewFileStore(path string) *FileStore { return &FileStore{path: path} } 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 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) { 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) } return state, 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(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) } data = append(data, '\n') temp, err := os.CreateTemp(filepath.Dir(s.path), ".profiles-*") if err != nil { return fmt.Errorf("create temporary Profile configuration: %w", err) } tempName := temp.Name() defer os.Remove(tempName) if err := temp.Chmod(0o600); err != nil { temp.Close() return fmt.Errorf("secure temporary Profile configuration: %w", err) } if _, err := temp.Write(data); err != nil { temp.Close() return fmt.Errorf("write temporary Profile configuration: %w", err) } if err := temp.Sync(); err != nil { temp.Close() return fmt.Errorf("sync temporary Profile configuration: %w", err) } 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) } return nil }