package store import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" ) // Store is the single writer of the metadata document. Every mutation runs on // a private copy of the state and is swapped in only once it has reached the // disk, so a reader never observes a half-applied update and a crash never // leaves a partial file. type Store struct { path string mu sync.RWMutex s *State } // Open loads the state document at path, creating an empty one if absent. func Open(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } st := &Store{path: path, s: newState()} b, err := os.ReadFile(path) switch { case os.IsNotExist(err): return st, st.persist(st.s) case err != nil: return nil, err } s := newState() if err := json.Unmarshal(b, s); err != nil { return nil, fmt.Errorf("store: parse %s: %w", path, err) } s.init() st.s = s return st, nil } // Read runs fn against a consistent view of the state. fn must not retain or // mutate anything it is given. func (st *Store) Read(fn func(*State)) { st.mu.RLock() defer st.mu.RUnlock() fn(st.s) } // Update applies fn to a copy of the state and commits it. If fn or the write // fails, the stored state is left untouched. func (st *Store) Update(fn func(*State) error) error { st.mu.Lock() defer st.mu.Unlock() next, err := clone(st.s) if err != nil { return err } if err := fn(next); err != nil { return err } if err := st.persist(next); err != nil { return err } st.s = next return nil } // GC drops expired sessions, device logins and invites. func (st *Store) GC(now time.Time) error { return st.Update(func(s *State) error { for k, v := range s.Sessions { if now.After(v.ExpiresAt) { delete(s.Sessions, k) } } for k, v := range s.Devices { if now.After(v.ExpiresAt) { delete(s.Devices, k) } } for k, v := range s.Invites { if !v.Usable(now) { delete(s.Invites, k) } } return nil }) } func (st *Store) persist(s *State) error { b, err := json.MarshalIndent(s, "", " ") if err != nil { return err } return writeFileAtomic(st.path, b, 0o600) } func clone(s *State) (*State, error) { b, err := json.Marshal(s) if err != nil { return nil, err } out := newState() if err := json.Unmarshal(b, out); err != nil { return nil, err } out.init() return out, nil } // writeFileAtomic replaces path with data, surviving a crash mid-write. func writeFileAtomic(path string, data []byte, mode os.FileMode) error { tmp := path + ".tmp" f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return err } if _, err := f.Write(data); err != nil { f.Close() os.Remove(tmp) return err } if err := f.Sync(); err != nil { f.Close() os.Remove(tmp) return err } if err := f.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) }