state: expose aids and use instance id as key
All checks were successful
Tests / Go tests (push) Successful in 39s
Nix / NixOS tests (push) Successful in 3m26s

Fortify state store instances was specific to aids due to outdated design decisions carried over from the ego rewrite. That no longer makes sense in the current application, so the interface now enables a single store object to manage all transient state.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
This commit is contained in:
Ophestra 2024-12-19 21:36:17 +09:00
parent 5ea7333431
commit eae3034260
Signed by: cat
SSH Key Fingerprint: SHA256:gQ67O0enBZ7UdZypgtspB2FDM1g3GVw8nX0XSdcFw8Q
8 changed files with 353 additions and 154 deletions

View File

@ -218,7 +218,7 @@ func (a *app) Seal(config *fst.Config) error {
// open process state store
// the simple store only starts holding an open file after first action
// store activity begins after Start is called and must end before Wait
seal.store = state.NewSimple(seal.RunDirPath, seal.sys.user.as)
seal.store = state.NewMulti(seal.RunDirPath)
// initialise system interface with full uid
seal.sys.I = system.New(seal.sys.user.uid)

View File

@ -76,8 +76,8 @@ func (a *app) Start() error {
// register process state
var err0 = new(StateStoreError)
err0.Inner, err0.DoErr = a.seal.store.Do(func(b state.Backend) {
err0.InnerErr = b.Save(&sd)
err0.Inner, err0.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(c state.Cursor) {
err0.InnerErr = c.Save(&sd)
})
a.seal.sys.saveState = true
return err0.equiv("cannot save process state:")
@ -199,11 +199,11 @@ func (a *app) Wait() (int, error) {
// update store and revert app setup transaction
e := new(StateStoreError)
e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) {
e.Inner, e.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(b state.Cursor) {
e.InnerErr = func() error {
// destroy defunct state entry
if cmd := a.shim.Unwrap(); cmd != nil && a.seal.sys.saveState {
if err := b.Destroy(cmd.Process.Pid); err != nil {
if err := b.Destroy(*a.id); err != nil {
return err
}
}

View File

@ -3,54 +3,135 @@ package state
import (
"encoding/gob"
"errors"
"fmt"
"io/fs"
"os"
"path"
"strconv"
"sync"
"syscall"
"git.ophivana.moe/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// fine-grained locking and access
type multiStore struct {
path []string
base string
// created/opened by prepare
lockfile *os.File
// enforce prepare method
init sync.Once
// error returned by prepare
initErr error
// initialised backends
backends *sync.Map
lock sync.Mutex
lock sync.RWMutex
}
func (s *multiStore) Do(f func(b Backend)) (bool, error) {
s.init.Do(s.prepare)
if s.initErr != nil {
return false, s.initErr
func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) {
s.lock.RLock()
defer s.lock.RUnlock()
// load or initialise new backend
b := new(multiBackend)
if v, ok := s.backends.LoadOrStore(aid, b); ok {
b = v.(*multiBackend)
} else {
b.lock.Lock()
b.path = path.Join(s.base, strconv.Itoa(aid))
// ensure directory
if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
s.backends.CompareAndDelete(aid, b)
return false, err
}
// open locker file
if l, err := os.OpenFile(b.path+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil {
s.backends.CompareAndDelete(aid, b)
return false, err
} else {
b.lockfile = l
}
b.lock.Unlock()
}
s.lock.Lock()
defer s.lock.Unlock()
// lock store
if err := s.lockFile(); err != nil {
// lock backend
if err := b.lockFile(); err != nil {
return false, err
}
// initialise new backend for caller
b := new(multiBackend)
b.path = path.Join(s.path...)
// expose backend methods without exporting the pointer
c := new(struct{ *multiBackend })
c.multiBackend = b
f(b)
// disable backend
b.lock.Lock()
// disable access to the backend on a best-effort basis
c.multiBackend = nil
// unlock store
return true, s.unlockFile()
// unlock backend
return true, b.unlockFile()
}
func (s *multiStore) lockFileAct(lt int) (err error) {
func (s *multiStore) List() ([]int, error) {
var entries []os.DirEntry
// read base directory to get all aids
if v, err := os.ReadDir(s.base); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
} else {
entries = v
}
aidsBuf := make([]int, 0, len(entries))
for _, e := range entries {
// skip non-directories
if !e.IsDir() {
fmsg.VPrintf("skipped non-directory entry %q", e.Name())
continue
}
// skip non-numerical names
if v, err := strconv.Atoi(e.Name()); err != nil {
fmsg.VPrintf("skipped non-aid entry %q", e.Name())
continue
} else {
if v < 0 || v > 9999 {
fmsg.VPrintf("skipped out of bounds entry %q", e.Name())
continue
}
aidsBuf = append(aidsBuf, v)
}
}
return append([]int(nil), aidsBuf...), nil
}
func (s *multiStore) Close() error {
s.lock.Lock()
defer s.lock.Unlock()
var errs []error
s.backends.Range(func(_, value any) bool {
b := value.(*multiBackend)
errs = append(errs, b.close())
return true
})
return errors.Join(errs...)
}
type multiBackend struct {
path string
// created/opened by prepare
lockfile *os.File
lock sync.RWMutex
}
func (b *multiBackend) filename(id *fst.ID) string {
return path.Join(b.path, id.String())
}
func (b *multiBackend) lockFileAct(lt int) (err error) {
op := "LockAct"
switch lt {
case syscall.LOCK_EX:
@ -60,7 +141,7 @@ func (s *multiStore) lockFileAct(lt int) (err error) {
}
for {
err = syscall.Flock(int(s.lockfile.Fd()), lt)
err = syscall.Flock(int(b.lockfile.Fd()), lt)
if !errors.Is(err, syscall.EINTR) {
break
}
@ -68,103 +149,80 @@ func (s *multiStore) lockFileAct(lt int) (err error) {
if err != nil {
return &fs.PathError{
Op: op,
Path: s.lockfile.Name(),
Path: b.lockfile.Name(),
Err: err,
}
}
return nil
}
func (s *multiStore) lockFile() error {
return s.lockFileAct(syscall.LOCK_EX)
func (b *multiBackend) lockFile() error {
return b.lockFileAct(syscall.LOCK_EX)
}
func (s *multiStore) unlockFile() error {
return s.lockFileAct(syscall.LOCK_UN)
}
func (s *multiStore) prepare() {
s.initErr = func() error {
prefix := path.Join(s.path...)
// ensure directory
if err := os.MkdirAll(prefix, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
// open locker file
if f, err := os.OpenFile(prefix+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil {
return err
} else {
s.lockfile = f
}
return nil
}()
}
func (s *multiStore) Close() error {
s.lock.Lock()
defer s.lock.Unlock()
err := s.lockfile.Close()
if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) {
return nil
}
return err
}
type multiBackend struct {
path string
lock sync.RWMutex
}
func (b *multiBackend) filename(pid int) string {
return path.Join(b.path, strconv.Itoa(pid))
func (b *multiBackend) unlockFile() error {
return b.lockFileAct(syscall.LOCK_UN)
}
// reads all launchers in simpleBackend
// file contents are ignored if decode is false
func (b *multiBackend) load(decode bool) ([]*State, error) {
func (b *multiBackend) load(decode bool) (Entries, error) {
b.lock.RLock()
defer b.lock.RUnlock()
var (
r []*State
f *os.File
)
// read directory contents, should only contain files named after PIDs
// read directory contents, should only contain files named after ids
var entries []os.DirEntry
if pl, err := os.ReadDir(b.path); err != nil {
return nil, err
} else {
for _, e := range pl {
// run in a function to better handle file closing
if err = func() error {
// open state file for reading
if f, err = os.Open(path.Join(b.path, e.Name())); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("foreign state file closed prematurely")
}
}()
entries = pl
}
var s State
r = append(r, &s)
// allocate as if every entry is valid
// since that should be the case assuming no external interference happens
r := make(Entries, len(entries))
// append regardless, but only parse if required, used to implement Len
if decode {
return gob.NewDecoder(f).Decode(&s)
} else {
return nil
for _, e := range entries {
if e.IsDir() {
return nil, fmt.Errorf("unexpected directory %q in store", e.Name())
}
id := new(fst.ID)
if err := fst.ParseAppID(id, e.Name()); err != nil {
return nil, err
}
// run in a function to better handle file closing
if err := func() error {
// open state file for reading
if f, err := os.Open(path.Join(b.path, e.Name())); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("foreign state file closed prematurely")
}
}()
s := new(State)
r[*id] = s
// append regardless, but only parse if required, used to implement Len
if decode {
if err = gob.NewDecoder(f).Decode(s); err != nil {
return err
}
if s.ID != *id {
return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID)
}
}
}(); err != nil {
return nil, err
return nil
}
}(); err != nil {
return nil, err
}
}
@ -180,7 +238,7 @@ func (b *multiBackend) Save(state *State) error {
return errors.New("state does not contain config")
}
statePath := b.filename(state.PID)
statePath := b.filename(&state.ID)
// create and open state data file
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
@ -197,14 +255,14 @@ func (b *multiBackend) Save(state *State) error {
}
}
func (b *multiBackend) Destroy(pid int) error {
func (b *multiBackend) Destroy(id fst.ID) error {
b.lock.Lock()
defer b.lock.Unlock()
return os.Remove(b.filename(pid))
return os.Remove(b.filename(&id))
}
func (b *multiBackend) Load() ([]*State, error) {
func (b *multiBackend) Load() (Entries, error) {
return b.load(true)
}
@ -214,9 +272,21 @@ func (b *multiBackend) Len() (int, error) {
return len(rn), err
}
// NewSimple returns an instance of a file-based store.
func NewSimple(runDir string, prefix ...string) Store {
func (b *multiBackend) close() error {
b.lock.Lock()
defer b.lock.Unlock()
err := b.lockfile.Close()
if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) {
return nil
}
return err
}
// NewMulti returns an instance of the multi-file store.
func NewMulti(runDir string) Store {
b := new(multiStore)
b.path = append([]string{runDir, "state"}, prefix...)
b.base = path.Join(runDir, "state")
b.backends = new(sync.Map)
return b
}

View File

@ -0,0 +1,11 @@
package state_test
import (
"testing"
"git.ophivana.moe/security/fortify/internal/state"
)
func TestMulti(t *testing.T) {
testStore(t, state.NewMulti(t.TempDir()))
}

View File

@ -1,11 +1,8 @@
package state
import (
"errors"
"fmt"
"os"
"path"
"strconv"
"strings"
"text/tabwriter"
"time"
@ -18,45 +15,31 @@ import (
// in an implementation-specific way.
func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) {
now := time.Now().UTC()
s := NewMulti(runDir)
// read runtime directory to get all UIDs
if dirs, err := os.ReadDir(path.Join(runDir, "state")); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Fatal("cannot read runtime directory:", err)
if aids, err := s.List(); err != nil {
fmsg.Fatal("cannot list store:", err)
} else {
for _, e := range dirs {
// skip non-directories
if !e.IsDir() {
fmsg.VPrintf("skipped non-directory entry %q", e.Name())
continue
}
// skip non-numerical names
if _, err = strconv.Atoi(e.Name()); err != nil {
fmsg.VPrintf("skipped non-uid entry %q", e.Name())
continue
}
// obtain temporary store
s := NewSimple(runDir, e.Name()).(*multiStore)
for _, aid := range aids {
// print states belonging to this store
s.mustPrintLauncherState(w, now)
// mustPrintLauncherState causes store activity so store needs to be closed
if err = s.Close(); err != nil {
fmsg.Printf("cannot close store for user %q: %s", e.Name(), err)
}
s.(*multiStore).mustPrintLauncherState(aid, w, now)
}
}
// mustPrintLauncherState causes store activity so store needs to be closed
if err := s.Close(); err != nil {
fmsg.Printf("cannot close store: %v", err)
}
}
func (s *multiStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time) {
func (s *multiStore) mustPrintLauncherState(aid int, w **tabwriter.Writer, now time.Time) {
var innerErr error
if ok, err := s.Do(func(b Backend) {
if ok, err := s.Do(aid, func(c Cursor) {
innerErr = func() error {
// read launcher states
states, err := b.Load()
states, err := c.Load()
if err != nil {
return err
}
@ -111,25 +94,25 @@ func (s *multiStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time)
}
if !fmsg.Verbose() {
_, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\t%s\t%s\n",
state.PID, s.path[len(s.path)-1], now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), cs)
_, _ = fmt.Fprintf(*w, "\t%d\t%d\t%s\t%s\t%s\n",
state.PID, aid, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), cs)
} else {
// emit argv instead when verbose
_, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\n",
state.PID, s.path[len(s.path)-1], state.ID)
_, _ = fmt.Fprintf(*w, "\t%d\t%d\t%s\n",
state.PID, aid, state.ID)
}
}
return nil
}()
}); err != nil {
fmsg.Printf("cannot perform action on store %q: %s", path.Join(s.path...), err)
fmsg.Printf("cannot perform action on app %d: %v", aid, err)
if !ok {
fmsg.Fatal("store faulted before printing")
}
}
if innerErr != nil {
fmsg.Fatalf("cannot print launcher state for store %q: %s", path.Join(s.path...), innerErr)
fmsg.Fatalf("cannot print launcher state of app %d: %s", aid, innerErr)
}
}

View File

@ -6,21 +6,27 @@ import (
"git.ophivana.moe/security/fortify/fst"
)
type Entries map[fst.ID]*State
type Store interface {
// Do calls f exactly once and ensures store exclusivity until f returns.
// Returns whether f is called and any errors during the locking process.
// Backend provided to f becomes invalid as soon as f returns.
Do(f func(b Backend)) (bool, error)
// Cursor provided to f becomes invalid as soon as f returns.
Do(aid int, f func(c Cursor)) (ok bool, err error)
// List queries the store and returns a list of aids known to the store.
// Note that some or all returned aids might not have any active apps.
List() (aids []int, err error)
// Close releases any resources held by Store.
Close() error
}
// Backend provides access to the store
type Backend interface {
// Cursor provides access to the store
type Cursor interface {
Save(state *State) error
Destroy(pid int) error
Load() ([]*State, error)
Destroy(id fst.ID) error
Load() (Entries, error)
Len() (int, error)
}

View File

@ -0,0 +1,126 @@
package state_test
import (
"math/rand/v2"
"reflect"
"slices"
"testing"
"time"
"git.ophivana.moe/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/state"
)
func testStore(t *testing.T, s state.Store) {
t.Run("list empty store", func(t *testing.T) {
if aids, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else if len(aids) != 0 {
t.Fatalf("List: aids = %#v", aids)
}
})
const (
insertEntryChecked = iota
insertEntryNoCheck
insertEntryOtherApp
tl
)
var tc [tl]state.State
for i := 0; i < tl; i++ {
makeState(t, &tc[i])
}
do := func(aid int, f func(c state.Cursor)) {
if ok, err := s.Do(aid, f); err != nil {
t.Fatalf("Do: ok = %v, error = %v", ok, err)
}
}
insert := func(i, aid int) {
do(aid, func(c state.Cursor) {
if err := c.Save(&tc[i]); err != nil {
t.Fatalf("Save(&tc[%v]): error = %v", i, err)
}
})
}
check := func(i, aid int) {
do(aid, func(c state.Cursor) {
if entries, err := c.Load(); err != nil {
t.Fatalf("Load: error = %v", err)
} else if got, ok := entries[tc[i].ID]; !ok {
t.Fatalf("Load: entry %s missing",
&tc[i].ID)
} else {
got.Time = tc[i].Time
if !reflect.DeepEqual(got, &tc[i]) {
t.Fatalf("Load: entry %s got %#v, want %#v",
&tc[i].ID, got, &tc[i])
}
}
})
}
t.Run("insert entry checked", func(t *testing.T) {
insert(insertEntryChecked, 0)
check(insertEntryChecked, 0)
})
t.Run("insert entry unchecked", func(t *testing.T) {
insert(insertEntryNoCheck, 0)
})
t.Run("insert entry different aid", func(t *testing.T) {
insert(insertEntryOtherApp, 1)
check(insertEntryOtherApp, 1)
})
t.Run("check previous insertion", func(t *testing.T) {
check(insertEntryNoCheck, 0)
})
t.Run("list aids", func(t *testing.T) {
if aids, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else {
slices.Sort(aids)
want := []int{0, 1}
if slices.Compare(aids, want) != 0 {
t.Fatalf("List() = %#v, want %#v", aids, want)
}
}
})
t.Run("clear aid 1", func(t *testing.T) {
do(1, func(c state.Cursor) {
if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil {
t.Fatalf("Destroy: error = %v", err)
}
})
do(1, func(c state.Cursor) {
if l, err := c.Len(); err != nil {
t.Fatalf("Len: error = %v", err)
} else if l != 0 {
t.Fatalf("Len() = %d, want 0", l)
}
})
})
t.Run("close store", func(t *testing.T) {
if err := s.Close(); err != nil {
t.Fatalf("Close: error = %v", err)
}
})
}
func makeState(t *testing.T, s *state.State) {
if err := fst.NewAppID(&s.ID); err != nil {
t.Fatalf("cannot create dummy state: %v", err)
}
s.Config = fst.Template()
s.PID = rand.Int()
s.Time = time.Now()
}

View File

@ -214,5 +214,8 @@ nixosTest {
swaymsg("exit", succeed=False)
machine.wait_until_fails("pgrep -x sway")
machine.wait_for_file("/tmp/sway-exit-ok")
# Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/fortify"))
'';
}