1package oshost23import (4 "errors"5 "fmt"6 "io/fs"7 "os"8 "path/filepath"9 "sort"1011 "github.com/FelineStateMachine/atlas/internal/app/hostenv"12)1314// Sessions keeps session records as files in one directory, one file per15// record, named exactly as the application named them.16//17// A record is written whole through a staged file and a rename, so a reader18// arriving mid-write sees the old record or the new one and never half of19// either. Nothing here knows what a record means: the schema belongs to the20// application and is documented in docs/app.md.21type Sessions struct{ dir string }2223// NewSessions answers for dir, creating it if it is not there yet. A host24// with nowhere to write should use hostenv.NewMemorySessions instead of25// pointing this at a directory it cannot keep.26func NewSessions(dir string) (*Sessions, error) {27 if err := os.MkdirAll(dir, 0o755); err != nil {28 return nil, fmt.Errorf("open session directory: %w", err)29 }30 return &Sessions{dir: dir}, nil31}3233// Dir is where the records are kept.34func (s *Sessions) Dir() string { return s.dir }3536// Load reads a record, or reports hostenv.ErrNoSession.37func (s *Sessions) Load(name string) ([]byte, error) {38 path, err := s.path(name)39 if err != nil {40 return nil, err41 }42 data, err := os.ReadFile(path)43 if errors.Is(err, fs.ErrNotExist) {44 return nil, hostenv.ErrNoSession45 }46 if err != nil {47 return nil, fmt.Errorf("read session %s: %w", name, err)48 }49 return data, nil50}5152// Save writes a record whole.53func (s *Sessions) Save(name string, data []byte) error {54 path, err := s.path(name)55 if err != nil {56 return err57 }58 staged, err := os.CreateTemp(s.dir, ".writing-*")59 if err != nil {60 return fmt.Errorf("write session %s: %w", name, err)61 }62 defer os.Remove(staged.Name())63 if _, err := staged.Write(data); err != nil {64 staged.Close()65 return fmt.Errorf("write session %s: %w", name, err)66 }67 if err := staged.Close(); err != nil {68 return fmt.Errorf("write session %s: %w", name, err)69 }70 if err := os.Chmod(staged.Name(), 0o644); err != nil {71 return fmt.Errorf("write session %s: %w", name, err)72 }73 if err := os.Rename(staged.Name(), path); err != nil {74 return fmt.Errorf("write session %s: %w", name, err)75 }76 return nil77}7879// Delete forgets a record by removing its file. A record that is not there is80// already what the caller asked for, so a missing file is success rather than81// an fs.ErrNotExist travelling up to a reader with nothing to do about it.82func (s *Sessions) Delete(name string) error {83 path, err := s.path(name)84 if err != nil {85 return err86 }87 if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {88 return fmt.Errorf("delete session %s: %w", name, err)89 }90 return nil91}9293// Names lists the records held, sorted. The staged files a concurrent save94// leaves behind are not records and are not listed.95func (s *Sessions) Names() ([]string, error) {96 entries, err := os.ReadDir(s.dir)97 if errors.Is(err, fs.ErrNotExist) {98 return nil, nil99 }100 if err != nil {101 return nil, fmt.Errorf("list sessions: %w", err)102 }103 names := make([]string, 0, len(entries))104 for _, entry := range entries {105 if entry.IsDir() || hostenv.ValidName(entry.Name()) != nil {106 continue107 }108 names = append(names, entry.Name())109 }110 sort.Strings(names)111 return names, nil112}113114// path is where a record lives. The name is held to hostenv.ValidName before115// it is joined to anything, which is what makes a record name safe to build116// out of a volume slug that arrived in a URL.117func (s *Sessions) path(name string) (string, error) {118 if err := hostenv.ValidName(name); err != nil {119 return "", fmt.Errorf("session %q: %w", name, err)120 }121 return filepath.Join(s.dir, name), nil122}