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 // open process state store
// the simple store only starts holding an open file after first action // the simple store only starts holding an open file after first action
// store activity begins after Start is called and must end before Wait // 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 // initialise system interface with full uid
seal.sys.I = system.New(seal.sys.user.uid) seal.sys.I = system.New(seal.sys.user.uid)

View File

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

View File

@ -3,54 +3,135 @@ package state
import ( import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os" "os"
"path" "path"
"strconv" "strconv"
"sync" "sync"
"syscall" "syscall"
"git.ophivana.moe/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/fmsg"
) )
// fine-grained locking and access // fine-grained locking and access
type multiStore struct { type multiStore struct {
path []string base string
// created/opened by prepare // initialised backends
lockfile *os.File backends *sync.Map
// enforce prepare method
init sync.Once
// error returned by prepare
initErr error
lock sync.Mutex lock sync.RWMutex
} }
func (s *multiStore) Do(f func(b Backend)) (bool, error) { func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) {
s.init.Do(s.prepare) s.lock.RLock()
if s.initErr != nil { defer s.lock.RUnlock()
return false, s.initErr
// 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() // lock backend
defer s.lock.Unlock() if err := b.lockFile(); err != nil {
// lock store
if err := s.lockFile(); err != nil {
return false, err return false, err
} }
// initialise new backend for caller // expose backend methods without exporting the pointer
b := new(multiBackend) c := new(struct{ *multiBackend })
b.path = path.Join(s.path...) c.multiBackend = b
f(b) f(b)
// disable backend // disable access to the backend on a best-effort basis
b.lock.Lock() c.multiBackend = nil
// unlock store // unlock backend
return true, s.unlockFile() 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" op := "LockAct"
switch lt { switch lt {
case syscall.LOCK_EX: case syscall.LOCK_EX:
@ -60,7 +141,7 @@ func (s *multiStore) lockFileAct(lt int) (err error) {
} }
for { for {
err = syscall.Flock(int(s.lockfile.Fd()), lt) err = syscall.Flock(int(b.lockfile.Fd()), lt)
if !errors.Is(err, syscall.EINTR) { if !errors.Is(err, syscall.EINTR) {
break break
} }
@ -68,103 +149,80 @@ func (s *multiStore) lockFileAct(lt int) (err error) {
if err != nil { if err != nil {
return &fs.PathError{ return &fs.PathError{
Op: op, Op: op,
Path: s.lockfile.Name(), Path: b.lockfile.Name(),
Err: err, Err: err,
} }
} }
return nil return nil
} }
func (s *multiStore) lockFile() error { func (b *multiBackend) lockFile() error {
return s.lockFileAct(syscall.LOCK_EX) return b.lockFileAct(syscall.LOCK_EX)
} }
func (s *multiStore) unlockFile() error { func (b *multiBackend) unlockFile() error {
return s.lockFileAct(syscall.LOCK_UN) return b.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))
} }
// reads all launchers in simpleBackend // reads all launchers in simpleBackend
// file contents are ignored if decode is false // 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() b.lock.RLock()
defer b.lock.RUnlock() defer b.lock.RUnlock()
var ( // read directory contents, should only contain files named after ids
r []*State var entries []os.DirEntry
f *os.File
)
// read directory contents, should only contain files named after PIDs
if pl, err := os.ReadDir(b.path); err != nil { if pl, err := os.ReadDir(b.path); err != nil {
return nil, err return nil, err
} else { } else {
for _, e := range pl { entries = 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")
}
}()
var s State // allocate as if every entry is valid
r = append(r, &s) // 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 for _, e := range entries {
if decode { if e.IsDir() {
return gob.NewDecoder(f).Decode(&s) return nil, fmt.Errorf("unexpected directory %q in store", e.Name())
} else { }
return nil
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") return errors.New("state does not contain config")
} }
statePath := b.filename(state.PID) statePath := b.filename(&state.ID)
// create and open state data file // create and open state data file
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil { 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() b.lock.Lock()
defer b.lock.Unlock() 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) return b.load(true)
} }
@ -214,9 +272,21 @@ func (b *multiBackend) Len() (int, error) {
return len(rn), err return len(rn), err
} }
// NewSimple returns an instance of a file-based store. func (b *multiBackend) close() error {
func NewSimple(runDir string, prefix ...string) Store { 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 := new(multiStore)
b.path = append([]string{runDir, "state"}, prefix...) b.base = path.Join(runDir, "state")
b.backends = new(sync.Map)
return b 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 package state
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path"
"strconv"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
@ -18,45 +15,31 @@ import (
// in an implementation-specific way. // in an implementation-specific way.
func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) { func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) {
now := time.Now().UTC() now := time.Now().UTC()
s := NewMulti(runDir)
// read runtime directory to get all UIDs // read runtime directory to get all UIDs
if dirs, err := os.ReadDir(path.Join(runDir, "state")); err != nil && !errors.Is(err, os.ErrNotExist) { if aids, err := s.List(); err != nil {
fmsg.Fatal("cannot read runtime directory:", err) fmsg.Fatal("cannot list store:", err)
} else { } else {
for _, e := range dirs { for _, aid := range aids {
// 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)
// print states belonging to this store // print states belonging to this store
s.mustPrintLauncherState(w, now) 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 for user %q: %s", e.Name(), err)
}
} }
} }
// 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 var innerErr error
if ok, err := s.Do(func(b Backend) { if ok, err := s.Do(aid, func(c Cursor) {
innerErr = func() error { innerErr = func() error {
// read launcher states // read launcher states
states, err := b.Load() states, err := c.Load()
if err != nil { if err != nil {
return err return err
} }
@ -111,25 +94,25 @@ func (s *multiStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time)
} }
if !fmsg.Verbose() { if !fmsg.Verbose() {
_, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%d\t%d\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) state.PID, aid, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), cs)
} else { } else {
// emit argv instead when verbose // emit argv instead when verbose
_, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%d\t%d\t%s\n",
state.PID, s.path[len(s.path)-1], state.ID) state.PID, aid, state.ID)
} }
} }
return nil return nil
}() }()
}); err != 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 { if !ok {
fmsg.Fatal("store faulted before printing") fmsg.Fatal("store faulted before printing")
} }
} }
if innerErr != nil { 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" "git.ophivana.moe/security/fortify/fst"
) )
type Entries map[fst.ID]*State
type Store interface { type Store interface {
// Do calls f exactly once and ensures store exclusivity until f returns. // Do calls f exactly once and ensures store exclusivity until f returns.
// Returns whether f is called and any errors during the locking process. // Returns whether f is called and any errors during the locking process.
// Backend provided to f becomes invalid as soon as f returns. // Cursor provided to f becomes invalid as soon as f returns.
Do(f func(b Backend)) (bool, error) 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 releases any resources held by Store.
Close() error Close() error
} }
// Backend provides access to the store // Cursor provides access to the store
type Backend interface { type Cursor interface {
Save(state *State) error Save(state *State) error
Destroy(pid int) error Destroy(id fst.ID) error
Load() ([]*State, error) Load() (Entries, error)
Len() (int, 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) swaymsg("exit", succeed=False)
machine.wait_until_fails("pgrep -x sway") machine.wait_until_fails("pgrep -x sway")
machine.wait_for_file("/tmp/sway-exit-ok") machine.wait_for_file("/tmp/sway-exit-ok")
# Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/fortify"))
''; '';
} }