aboutsummaryrefslogtreecommitdiffhomepage
path: root/system
diff options
context:
space:
mode:
Diffstat (limited to 'system')
-rw-r--r--system/acl.go65
-rw-r--r--system/acl_test.go90
-rw-r--r--system/dbus.go148
-rw-r--r--system/enablement.go68
-rw-r--r--system/link.go47
-rw-r--r--system/mkdir.go86
-rw-r--r--system/mkdir_test.go73
-rw-r--r--system/op.go162
-rw-r--r--system/op_internal_test.go79
-rw-r--r--system/op_test.go129
-rw-r--r--system/tmpfiles.go76
-rw-r--r--system/tmpfiles_test.go81
-rw-r--r--system/wayland.go89
-rw-r--r--system/xhost.go53
-rw-r--r--system/xhost_test.go34
15 files changed, 1280 insertions, 0 deletions
diff --git a/system/acl.go b/system/acl.go
new file mode 100644
index 00000000..840c3971
--- /dev/null
+++ b/system/acl.go
@@ -0,0 +1,65 @@
+package system
+
+import (
+ "fmt"
+ "slices"
+
+ "git.gensokyo.uk/security/fortify/acl"
+)
+
+// UpdatePerm appends an ephemeral acl update Op.
+func (sys *I) UpdatePerm(path string, perms ...acl.Perm) *I {
+ sys.UpdatePermType(Process, path, perms...)
+
+ return sys
+}
+
+// UpdatePermType appends an acl update Op.
+func (sys *I) UpdatePermType(et Enablement, path string, perms ...acl.Perm) *I {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ sys.ops = append(sys.ops, &ACL{et, path, perms})
+
+ return sys
+}
+
+type ACL struct {
+ et Enablement
+ path string
+ perms acl.Perms
+}
+
+func (a *ACL) Type() Enablement { return a.et }
+
+func (a *ACL) apply(sys *I) error {
+ sys.println("applying ACL", a)
+ return sys.wrapErrSuffix(acl.UpdatePerm(a.path, sys.uid, a.perms...),
+ fmt.Sprintf("cannot apply ACL entry to %q:", a.path))
+}
+
+func (a *ACL) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(a) {
+ sys.println("stripping ACL", a)
+ return sys.wrapErrSuffix(acl.UpdatePerm(a.path, sys.uid),
+ fmt.Sprintf("cannot strip ACL entry from %q:", a.path))
+ } else {
+ sys.println("skipping ACL", a)
+ return nil
+ }
+}
+
+func (a *ACL) Is(o Op) bool {
+ a0, ok := o.(*ACL)
+ return ok && a0 != nil &&
+ a.et == a0.et &&
+ a.path == a0.path &&
+ slices.Equal(a.perms, a0.perms)
+}
+
+func (a *ACL) Path() string { return a.path }
+
+func (a *ACL) String() string {
+ return fmt.Sprintf("%s type: %s path: %q",
+ a.perms, TypeString(a.et), a.path)
+}
diff --git a/system/acl_test.go b/system/acl_test.go
new file mode 100644
index 00000000..ed154ad1
--- /dev/null
+++ b/system/acl_test.go
@@ -0,0 +1,90 @@
+package system
+
+import (
+ "testing"
+
+ "git.gensokyo.uk/security/fortify/acl"
+)
+
+func TestUpdatePerm(t *testing.T) {
+ testCases := []struct {
+ path string
+ perms []acl.Perm
+ }{
+ {"/run/user/1971/fortify", []acl.Perm{acl.Execute}},
+ {"/tmp/fortify.1971/tmpdir/150", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.path+permSubTestSuffix(tc.perms), func(t *testing.T) {
+ sys := New(150)
+ sys.UpdatePerm(tc.path, tc.perms...)
+ (&tcOp{Process, tc.path}).test(t, sys.ops, []Op{&ACL{Process, tc.path, tc.perms}}, "UpdatePerm")
+ })
+ }
+}
+
+func TestUpdatePermType(t *testing.T) {
+ testCases := []struct {
+ perms []acl.Perm
+ tcOp
+ }{
+ {[]acl.Perm{acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir"}},
+ {[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir/150"}},
+ {[]acl.Perm{acl.Execute}, tcOp{Process, "/run/user/1971/fortify/fcb8a12f7c482d183ade8288c3de78b5"}},
+ {[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/passwd"}},
+ {[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/group"}},
+ {[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{EWayland, "/run/user/1971/wayland-0"}},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.path+"_"+TypeString(tc.et)+permSubTestSuffix(tc.perms), func(t *testing.T) {
+ sys := New(150)
+ sys.UpdatePermType(tc.et, tc.path, tc.perms...)
+ tc.test(t, sys.ops, []Op{&ACL{tc.et, tc.path, tc.perms}}, "UpdatePermType")
+ })
+ }
+}
+
+func TestACL_String(t *testing.T) {
+ testCases := []struct {
+ want string
+ et Enablement
+ perms []acl.Perm
+ }{
+ {`--- type: Process path: "/nonexistent"`, Process, []acl.Perm{}},
+ {`r-- type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read}},
+ {`-w- type: Wayland path: "/nonexistent"`, EWayland, []acl.Perm{acl.Write}},
+ {`--x type: X11 path: "/nonexistent"`, EX11, []acl.Perm{acl.Execute}},
+ {`rw- type: D-Bus path: "/nonexistent"`, EDBus, []acl.Perm{acl.Read, acl.Write}},
+ {`r-x type: PulseAudio path: "/nonexistent"`, EPulse, []acl.Perm{acl.Read, acl.Execute}},
+ {`rwx type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read, acl.Write, acl.Execute}},
+ {`rwx type: Process path: "/nonexistent"`, Process, []acl.Perm{acl.Read, acl.Write, acl.Write, acl.Execute}},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.want, func(t *testing.T) {
+ a := &ACL{et: tc.et, perms: tc.perms, path: "/nonexistent"}
+ if got := a.String(); got != tc.want {
+ t.Errorf("String() = %v, want %v",
+ got, tc.want)
+ }
+ })
+ }
+}
+
+func permSubTestSuffix(perms []acl.Perm) (suffix string) {
+ for _, perm := range perms {
+ switch perm {
+ case acl.Read:
+ suffix += "_read"
+ case acl.Write:
+ suffix += "_write"
+ case acl.Execute:
+ suffix += "_execute"
+ default:
+ panic("unreachable")
+ }
+ }
+ return
+}
diff --git a/system/dbus.go b/system/dbus.go
new file mode 100644
index 00000000..cbb127ed
--- /dev/null
+++ b/system/dbus.go
@@ -0,0 +1,148 @@
+package system
+
+import (
+ "bytes"
+ "errors"
+ "log"
+ "strings"
+ "sync"
+
+ "git.gensokyo.uk/security/fortify/dbus"
+)
+
+var (
+ ErrDBusConfig = errors.New("dbus config not supplied")
+)
+
+func (sys *I) MustProxyDBus(sessionPath string, session *dbus.Config, systemPath string, system *dbus.Config) *I {
+ if _, err := sys.ProxyDBus(session, system, sessionPath, systemPath); err != nil {
+ panic(err.Error())
+ } else {
+ return sys
+ }
+}
+
+func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) (func(), error) {
+ d := new(DBus)
+
+ // session bus is mandatory
+ if session == nil {
+ return nil, sys.wrapErr(ErrDBusConfig,
+ "attempted to seal message bus proxy without session bus config")
+ }
+
+ // system bus is optional
+ d.system = system != nil
+
+ // upstream address, downstream socket path
+ var sessionBus, systemBus [2]string
+
+ // resolve upstream bus addresses
+ sessionBus[0], systemBus[0] = dbus.Address()
+
+ // set paths from caller
+ sessionBus[1], systemBus[1] = sessionPath, systemPath
+
+ // create proxy instance
+ d.proxy = dbus.New(sessionBus, systemBus)
+
+ defer func() {
+ if sys.IsVerbose() && d.proxy.Sealed() {
+ sys.println("sealed session proxy", session.Args(sessionBus))
+ if system != nil {
+ sys.println("sealed system proxy", system.Args(systemBus))
+ }
+ sys.println("message bus proxy final args:", d.proxy)
+ }
+ }()
+
+ // queue operation
+ sys.ops = append(sys.ops, d)
+
+ // seal dbus proxy
+ d.out = &scanToFmsg{msg: new(strings.Builder)}
+ return d.out.Dump, sys.wrapErrSuffix(d.proxy.Seal(session, system),
+ "cannot seal message bus proxy:")
+}
+
+type DBus struct {
+ proxy *dbus.Proxy
+
+ out *scanToFmsg
+ // whether system bus proxy is enabled
+ system bool
+}
+
+func (d *DBus) Type() Enablement { return Process }
+
+func (d *DBus) apply(sys *I) error {
+ sys.printf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0])
+ if d.system {
+ sys.printf("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0])
+ }
+
+ // this starts the process and blocks until ready
+ if err := d.proxy.Start(sys.ctx, d.out, true); err != nil {
+ d.out.Dump()
+ return sys.wrapErrSuffix(err,
+ "cannot start message bus proxy:")
+ }
+ sys.println("starting message bus proxy:", d.proxy)
+ return nil
+}
+
+func (d *DBus) revert(sys *I, _ *Criteria) error {
+ // criteria ignored here since dbus is always process-scoped
+ sys.println("terminating message bus proxy")
+ d.proxy.Close()
+ defer sys.println("message bus proxy exit")
+ return sys.wrapErrSuffix(d.proxy.Wait(), "message bus proxy error:")
+}
+
+func (d *DBus) Is(o Op) bool {
+ d0, ok := o.(*DBus)
+ return ok && d0 != nil &&
+ ((d.proxy == nil && d0.proxy == nil) ||
+ (d.proxy != nil && d0.proxy != nil && d.proxy.String() == d0.proxy.String()))
+}
+
+func (d *DBus) Path() string {
+ return "(dbus proxy)"
+}
+
+func (d *DBus) String() string {
+ return d.proxy.String()
+}
+
+type scanToFmsg struct {
+ msg *strings.Builder
+ msgbuf []string
+
+ mu sync.RWMutex
+}
+
+func (s *scanToFmsg) Write(p []byte) (n int, err error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.write(p, 0)
+}
+
+func (s *scanToFmsg) write(p []byte, a int) (int, error) {
+ if i := bytes.IndexByte(p, '\n'); i == -1 {
+ n, _ := s.msg.Write(p)
+ return a + n, nil
+ } else {
+ n, _ := s.msg.Write(p[:i])
+ s.msgbuf = append(s.msgbuf, s.msg.String())
+ s.msg.Reset()
+ return s.write(p[i+1:], a+n+1)
+ }
+}
+
+func (s *scanToFmsg) Dump() {
+ s.mu.RLock()
+ for _, msg := range s.msgbuf {
+ log.Println(msg)
+ }
+ s.mu.RUnlock()
+}
diff --git a/system/enablement.go b/system/enablement.go
new file mode 100644
index 00000000..f0911d54
--- /dev/null
+++ b/system/enablement.go
@@ -0,0 +1,68 @@
+package system
+
+import (
+ "strings"
+)
+
+type (
+ // Enablement represents an optional system resource
+ Enablement uint8
+ // Enablements represents optional system resources to share
+ Enablements uint64
+)
+
+const (
+ EWayland Enablement = iota
+ EX11
+ EDBus
+ EPulse
+)
+
+var enablementString = [...]string{
+ EWayland: "Wayland",
+ EX11: "X11",
+ EDBus: "D-Bus",
+ EPulse: "PulseAudio",
+}
+
+const ELen = len(enablementString)
+
+func (e Enablement) String() string {
+ if int(e) >= ELen {
+ return "<invalid enablement>"
+ }
+ return enablementString[e]
+}
+
+func (e Enablement) Mask() Enablements {
+ return 1 << e
+}
+
+// Has returns whether a feature is enabled
+func (es *Enablements) Has(e Enablement) bool {
+ return *es&e.Mask() != 0
+}
+
+// Set enables a feature
+func (es *Enablements) Set(e Enablement) {
+ if es.Has(e) {
+ panic("enablement " + e.String() + " set twice")
+ }
+
+ *es |= e.Mask()
+}
+
+func (es *Enablements) String() string {
+ buf := new(strings.Builder)
+ for i := Enablement(0); i < Enablement(ELen); i++ {
+ if es.Has(i) {
+ buf.WriteString(", " + i.String())
+ }
+ }
+
+ if buf.Len() == 0 {
+ buf.WriteString("(No enablements)")
+ }
+
+ return strings.TrimPrefix(buf.String(), ", ")
+}
diff --git a/system/link.go b/system/link.go
new file mode 100644
index 00000000..4182d560
--- /dev/null
+++ b/system/link.go
@@ -0,0 +1,47 @@
+package system
+
+import (
+ "fmt"
+ "os"
+)
+
+// Link registers an Op that links dst to src.
+func (sys *I) Link(oldname, newname string) *I { return sys.LinkFileType(Process, oldname, newname) }
+
+// LinkFileType registers a file linking Op labelled with type et.
+func (sys *I) LinkFileType(et Enablement, oldname, newname string) *I {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ sys.ops = append(sys.ops, &Hardlink{et, newname, oldname})
+
+ return sys
+}
+
+type Hardlink struct {
+ et Enablement
+ dst, src string
+}
+
+func (l *Hardlink) Type() Enablement { return l.et }
+
+func (l *Hardlink) apply(sys *I) error {
+ sys.println("linking", l)
+ return sys.wrapErrSuffix(os.Link(l.src, l.dst),
+ fmt.Sprintf("cannot link %q:", l.dst))
+}
+
+func (l *Hardlink) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(l) {
+ sys.printf("removing hard link %q", l.dst)
+ return sys.wrapErrSuffix(os.Remove(l.dst),
+ fmt.Sprintf("cannot remove hard link %q:", l.dst))
+ } else {
+ sys.printf("skipping hard link %q", l.dst)
+ return nil
+ }
+}
+
+func (l *Hardlink) Is(o Op) bool { l0, ok := o.(*Hardlink); return ok && l0 != nil && *l == *l0 }
+func (l *Hardlink) Path() string { return l.src }
+func (l *Hardlink) String() string { return fmt.Sprintf("%q from %q", l.dst, l.src) }
diff --git a/system/mkdir.go b/system/mkdir.go
new file mode 100644
index 00000000..524289e5
--- /dev/null
+++ b/system/mkdir.go
@@ -0,0 +1,86 @@
+package system
+
+import (
+ "errors"
+ "fmt"
+ "os"
+)
+
+// Ensure the existence and mode of a directory.
+func (sys *I) Ensure(name string, perm os.FileMode) *I {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ sys.ops = append(sys.ops, &Mkdir{User, name, perm, false})
+
+ return sys
+}
+
+// Ephemeral ensures the temporary existence and mode of a directory through the life of et.
+func (sys *I) Ephemeral(et Enablement, name string, perm os.FileMode) *I {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ sys.ops = append(sys.ops, &Mkdir{et, name, perm, true})
+
+ return sys
+}
+
+type Mkdir struct {
+ et Enablement
+ path string
+ perm os.FileMode
+ ephemeral bool
+}
+
+func (m *Mkdir) Type() Enablement {
+ return m.et
+}
+
+func (m *Mkdir) apply(sys *I) error {
+ sys.println("ensuring directory", m)
+
+ // create directory
+ err := os.Mkdir(m.path, m.perm)
+ if !errors.Is(err, os.ErrExist) {
+ return sys.wrapErrSuffix(err,
+ fmt.Sprintf("cannot create directory %q:", m.path))
+ }
+
+ // directory exists, ensure mode
+ return sys.wrapErrSuffix(os.Chmod(m.path, m.perm),
+ fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm))
+}
+
+func (m *Mkdir) revert(sys *I, ec *Criteria) error {
+ if !m.ephemeral {
+ // skip non-ephemeral dir and do not log anything
+ return nil
+ }
+
+ if ec.hasType(m) {
+ sys.println("destroying ephemeral directory", m)
+ return sys.wrapErrSuffix(os.Remove(m.path),
+ fmt.Sprintf("cannot remove ephemeral directory %q:", m.path))
+ } else {
+ sys.println("skipping ephemeral directory", m)
+ return nil
+ }
+}
+
+func (m *Mkdir) Is(o Op) bool {
+ m0, ok := o.(*Mkdir)
+ return ok && m0 != nil && *m == *m0
+}
+
+func (m *Mkdir) Path() string {
+ return m.path
+}
+
+func (m *Mkdir) String() string {
+ t := "Ensure"
+ if m.ephemeral {
+ t = TypeString(m.Type())
+ }
+ return fmt.Sprintf("mode: %s type: %s path: %q", m.perm.String(), t, m.path)
+}
diff --git a/system/mkdir_test.go b/system/mkdir_test.go
new file mode 100644
index 00000000..20826940
--- /dev/null
+++ b/system/mkdir_test.go
@@ -0,0 +1,73 @@
+package system
+
+import (
+ "os"
+ "testing"
+)
+
+func TestEnsure(t *testing.T) {
+ testCases := []struct {
+ name string
+ perm os.FileMode
+ }{
+ {"/tmp/fortify.1971", 0701},
+ {"/tmp/fortify.1971/tmpdir", 0700},
+ {"/tmp/fortify.1971/tmpdir/150", 0700},
+ {"/run/user/1971/fortify", 0700},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name+"_"+tc.perm.String(), func(t *testing.T) {
+ sys := New(150)
+ sys.Ensure(tc.name, tc.perm)
+ (&tcOp{User, tc.name}).test(t, sys.ops, []Op{&Mkdir{User, tc.name, tc.perm, false}}, "Ensure")
+ })
+ }
+}
+
+func TestEphemeral(t *testing.T) {
+ testCases := []struct {
+ perm os.FileMode
+ tcOp
+ }{
+ {0700, tcOp{Process, "/run/user/1971/fortify/ec07546a772a07cde87389afc84ffd13"}},
+ {0701, tcOp{Process, "/tmp/fortify.1971/ec07546a772a07cde87389afc84ffd13"}},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.path+"_"+tc.perm.String()+"_"+TypeString(tc.et), func(t *testing.T) {
+ sys := New(150)
+ sys.Ephemeral(tc.et, tc.path, tc.perm)
+ tc.test(t, sys.ops, []Op{&Mkdir{tc.et, tc.path, tc.perm, true}}, "Ephemeral")
+ })
+ }
+}
+
+func TestMkdir_String(t *testing.T) {
+ testCases := []struct {
+ want string
+ ephemeral bool
+ et Enablement
+ }{
+ {"Ensure", false, User},
+ {"Ensure", false, Process},
+ {"Ensure", false, EWayland},
+
+ {"Wayland", true, EWayland},
+ {"X11", true, EX11},
+ {"D-Bus", true, EDBus},
+ {"PulseAudio", true, EPulse},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.want, func(t *testing.T) {
+ m := &Mkdir{
+ et: tc.et,
+ path: "/nonexistent",
+ perm: 0701,
+ ephemeral: tc.ephemeral,
+ }
+ want := "mode: " + os.FileMode(0701).String() + " type: " + tc.want + " path: \"/nonexistent\""
+ if got := m.String(); got != want {
+ t.Errorf("String() = %v, want %v", got, want)
+ }
+ })
+ }
+}
diff --git a/system/op.go b/system/op.go
new file mode 100644
index 00000000..0e421aec
--- /dev/null
+++ b/system/op.go
@@ -0,0 +1,162 @@
+package system
+
+import (
+ "context"
+ "errors"
+ "log"
+ "sync"
+)
+
+const (
+ // User type is reverted at final launcher exit.
+ User = Enablement(ELen)
+ // Process type is unconditionally reverted on exit.
+ Process = Enablement(ELen + 1)
+)
+
+type Criteria struct {
+ *Enablements
+}
+
+func (ec *Criteria) hasType(o Op) bool {
+ // nil criteria: revert everything except User
+ if ec.Enablements == nil {
+ return o.Type() != User
+ }
+
+ return ec.Has(o.Type())
+}
+
+// Op is a reversible system operation.
+type Op interface {
+ // Type returns Op's enablement type.
+ Type() Enablement
+
+ // apply the Op
+ apply(sys *I) error
+ // revert reverses the Op if criteria is met
+ revert(sys *I, ec *Criteria) error
+
+ Is(o Op) bool
+ Path() string
+ String() string
+}
+
+func TypeString(e Enablement) string {
+ switch e {
+ case User:
+ return "User"
+ case Process:
+ return "Process"
+ default:
+ return e.String()
+ }
+}
+
+// New initialises sys with no-op verbose functions.
+func New(uid int) (sys *I) {
+ sys = new(I)
+ sys.uid = uid
+ sys.IsVerbose = func() bool { return false }
+ sys.Verbose = func(...any) {}
+ sys.Verbosef = func(string, ...any) {}
+ sys.WrapErr = func(err error, _ ...any) error { return err }
+ return
+}
+
+type I struct {
+ uid int
+ ops []Op
+ ctx context.Context
+
+ IsVerbose func() bool
+ Verbose func(v ...any)
+ Verbosef func(format string, v ...any)
+ WrapErr func(err error, a ...any) error
+
+ // whether sys has been reverted
+ state bool
+
+ lock sync.Mutex
+}
+
+func (sys *I) UID() int { return sys.uid }
+func (sys *I) println(v ...any) { sys.Verbose(v...) }
+func (sys *I) printf(format string, v ...any) { sys.Verbosef(format, v...) }
+func (sys *I) wrapErr(err error, a ...any) error { return sys.WrapErr(err, a...) }
+func (sys *I) wrapErrSuffix(err error, a ...any) error {
+ if err == nil {
+ return nil
+ }
+ return sys.wrapErr(err, append(a, err)...)
+}
+
+func (sys *I) Equal(v *I) bool {
+ if v == nil || sys.uid != v.uid || len(sys.ops) != len(v.ops) {
+ return false
+ }
+
+ for i, o := range sys.ops {
+ if !o.Is(v.ops[i]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (sys *I) Commit(ctx context.Context) error {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ if sys.ctx != nil {
+ panic("sys instance committed twice")
+ }
+ sys.ctx = ctx
+
+ sp := New(sys.uid)
+ sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits
+ defer func() {
+ // sp is set to nil when all ops are applied
+ if sp != nil {
+ // rollback partial commit
+ sys.printf("commit faulted after %d ops, rolling back partial commit", len(sp.ops))
+ if err := sp.Revert(&Criteria{nil}); err != nil {
+ log.Println("errors returned reverting partial commit:", err)
+ }
+ }
+ }()
+
+ for _, o := range sys.ops {
+ if err := o.apply(sys); err != nil {
+ return err
+ } else {
+ // register partial commit
+ sp.ops = append(sp.ops, o)
+ }
+ }
+
+ // disarm partial commit rollback
+ sp = nil
+ return nil
+}
+
+func (sys *I) Revert(ec *Criteria) error {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ if sys.state {
+ panic("sys instance reverted twice")
+ }
+ sys.state = true
+
+ // collect errors
+ errs := make([]error, len(sys.ops))
+
+ for i := range sys.ops {
+ errs[i] = sys.ops[len(sys.ops)-i-1].revert(sys, ec)
+ }
+
+ // errors.Join filters nils
+ return errors.Join(errs...)
+}
diff --git a/system/op_internal_test.go b/system/op_internal_test.go
new file mode 100644
index 00000000..7ec4f174
--- /dev/null
+++ b/system/op_internal_test.go
@@ -0,0 +1,79 @@
+package system
+
+import "testing"
+
+type tcOp struct {
+ et Enablement
+ path string
+}
+
+// test an instance of the Op interface
+func (ptc tcOp) test(t *testing.T, gotOps []Op, wantOps []Op, fn string) {
+ if len(gotOps) != len(wantOps) {
+ t.Errorf("%s: inserted %v Ops, want %v", fn,
+ len(gotOps), len(wantOps))
+ return
+ }
+
+ t.Run("path", func(t *testing.T) {
+ if len(gotOps) > 0 {
+ if got := gotOps[0].Path(); got != ptc.path {
+ t.Errorf("Path() = %q, want %q",
+ got, ptc.path)
+ return
+ }
+ }
+ })
+
+ for i := range gotOps {
+ o := gotOps[i]
+
+ t.Run("is", func(t *testing.T) {
+ if !o.Is(o) {
+ t.Errorf("Is returned false on self")
+ return
+ }
+ if !o.Is(wantOps[i]) {
+ t.Errorf("%s: inserted %#v, want %#v",
+ fn,
+ o, wantOps[i])
+ return
+ }
+ })
+
+ t.Run("criteria", func(t *testing.T) {
+ testCases := []struct {
+ name string
+ ec *Criteria
+ want bool
+ }{
+ {"nil", newCriteria(), ptc.et != User},
+ {"self", newCriteria(ptc.et), true},
+ {"all", newCriteria(EWayland, EX11, EDBus, EPulse, User, Process), true},
+ {"enablements", newCriteria(EWayland, EX11, EDBus, EPulse), ptc.et != User && ptc.et != Process},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.ec.hasType(o); got != tc.want {
+ t.Errorf("hasType: got %v, want %v",
+ got, tc.want)
+ }
+ })
+ }
+ })
+ }
+}
+
+func newCriteria(labels ...Enablement) *Criteria {
+ ec := new(Criteria)
+ if len(labels) == 0 {
+ return ec
+ }
+
+ ec.Enablements = new(Enablements)
+ for _, e := range labels {
+ ec.Set(e)
+ }
+ return ec
+}
diff --git a/system/op_test.go b/system/op_test.go
new file mode 100644
index 00000000..9d179699
--- /dev/null
+++ b/system/op_test.go
@@ -0,0 +1,129 @@
+package system_test
+
+import (
+ "strconv"
+ "testing"
+
+ "git.gensokyo.uk/security/fortify/system"
+)
+
+func TestNew(t *testing.T) {
+ testCases := []struct {
+ uid int
+ }{
+ {150},
+ {149},
+ {148},
+ {147},
+ }
+
+ for _, tc := range testCases {
+ t.Run("sys initialised with uid "+strconv.Itoa(tc.uid), func(t *testing.T) {
+ if got := system.New(tc.uid); got.UID() != tc.uid {
+ t.Errorf("New(%d) uid = %d, want %d",
+ tc.uid,
+ got.UID(), tc.uid)
+ }
+ })
+ }
+}
+
+func TestTypeString(t *testing.T) {
+ testCases := []struct {
+ e system.Enablement
+ want string
+ }{
+ {system.EWayland, system.EWayland.String()},
+ {system.EX11, system.EX11.String()},
+ {system.EDBus, system.EDBus.String()},
+ {system.EPulse, system.EPulse.String()},
+ {system.User, "User"},
+ {system.Process, "Process"},
+ }
+
+ for _, tc := range testCases {
+ t.Run("label type string "+tc.want, func(t *testing.T) {
+ if got := system.TypeString(tc.e); got != tc.want {
+ t.Errorf("TypeString(%d) = %v, want %v",
+ tc.e,
+ got, tc.want)
+ }
+ })
+ }
+}
+
+func TestI_Equal(t *testing.T) {
+ testCases := []struct {
+ name string
+ sys *system.I
+ v *system.I
+ want bool
+ }{
+ {
+ "simple UID",
+ system.New(150),
+ system.New(150),
+ true,
+ },
+ {
+ "simple UID differ",
+ system.New(150),
+ system.New(151),
+ false,
+ },
+ {
+ "simple UID nil",
+ system.New(150),
+ nil,
+ false,
+ },
+ {
+ "op length mismatch",
+ system.New(150).
+ ChangeHosts("chronos"),
+ system.New(150).
+ ChangeHosts("chronos").
+ Ensure("/run", 0755),
+ false,
+ },
+ {
+ "op value mismatch",
+ system.New(150).
+ ChangeHosts("chronos").
+ Ensure("/run", 0644),
+ system.New(150).
+ ChangeHosts("chronos").
+ Ensure("/run", 0755),
+ false,
+ },
+ {
+ "op type mismatch",
+ system.New(150).
+ ChangeHosts("chronos").
+ CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 0, 256),
+ system.New(150).
+ ChangeHosts("chronos").
+ Ensure("/run", 0755),
+ false,
+ },
+ {
+ "op equals",
+ system.New(150).
+ ChangeHosts("chronos").
+ Ensure("/run", 0755),
+ system.New(150).
+ ChangeHosts("chronos").
+ Ensure("/run", 0755),
+ true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.sys.Equal(tc.v) != tc.want {
+ t.Errorf("Equal: got %v; want %v",
+ !tc.want, tc.want)
+ }
+ })
+ }
+}
diff --git a/system/tmpfiles.go b/system/tmpfiles.go
new file mode 100644
index 00000000..636943bc
--- /dev/null
+++ b/system/tmpfiles.go
@@ -0,0 +1,76 @@
+package system
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "syscall"
+)
+
+// CopyFile registers an Op that copies from src.
+// A buffer is initialised with size cap and the Op faults if bytes read exceed n.
+func (sys *I) CopyFile(payload *[]byte, src string, cap int, n int64) *I {
+ buf := new(bytes.Buffer)
+ buf.Grow(cap)
+
+ sys.lock.Lock()
+ sys.ops = append(sys.ops, &Tmpfile{payload, src, n, buf})
+ sys.lock.Unlock()
+
+ return sys
+}
+
+type Tmpfile struct {
+ payload *[]byte
+ src string
+
+ n int64
+ buf *bytes.Buffer
+}
+
+func (t *Tmpfile) Type() Enablement { return Process }
+func (t *Tmpfile) apply(sys *I) error {
+ sys.println("copying", t)
+
+ if t.payload == nil {
+ // this is a misuse of the API; do not return an error message
+ return errors.New("invalid payload")
+ }
+
+ if b, err := os.Stat(t.src); err != nil {
+ return sys.wrapErrSuffix(err,
+ fmt.Sprintf("cannot stat %q:", t.src))
+ } else {
+ if b.IsDir() {
+ return sys.wrapErrSuffix(syscall.EISDIR,
+ fmt.Sprintf("%q is a directory", t.src))
+ }
+ if s := b.Size(); s > t.n {
+ return sys.wrapErrSuffix(syscall.ENOMEM,
+ fmt.Sprintf("file %q is too long: %d > %d",
+ t.src, s, t.n))
+ }
+ }
+
+ if f, err := os.Open(t.src); err != nil {
+ return sys.wrapErrSuffix(err,
+ fmt.Sprintf("cannot open %q:", t.src))
+ } else if _, err = io.CopyN(t.buf, f, t.n); err != nil {
+ return sys.wrapErrSuffix(err,
+ fmt.Sprintf("cannot read from %q:", t.src))
+ }
+
+ *t.payload = t.buf.Bytes()
+ return nil
+}
+func (t *Tmpfile) revert(*I, *Criteria) error { t.buf.Reset(); return nil }
+
+func (t *Tmpfile) Is(o Op) bool {
+ t0, ok := o.(*Tmpfile)
+ return ok && t0 != nil &&
+ t.src == t0.src && t.n == t0.n
+}
+func (t *Tmpfile) Path() string { return t.src }
+func (t *Tmpfile) String() string { return fmt.Sprintf("up to %d bytes from %q", t.n, t.src) }
diff --git a/system/tmpfiles_test.go b/system/tmpfiles_test.go
new file mode 100644
index 00000000..c7402f59
--- /dev/null
+++ b/system/tmpfiles_test.go
@@ -0,0 +1,81 @@
+package system
+
+import (
+ "strconv"
+ "testing"
+)
+
+func TestCopyFile(t *testing.T) {
+ testCases := []struct {
+ tcOp
+ cap int
+ n int64
+ }{
+ {tcOp{Process, "/home/ophestra/xdg/config/pulse/cookie"}, 256, 256},
+ }
+ for _, tc := range testCases {
+ t.Run("copy file "+tc.path+" with cap = "+strconv.Itoa(tc.cap)+" n = "+strconv.Itoa(int(tc.n)), func(t *testing.T) {
+ sys := New(150)
+ sys.CopyFile(new([]byte), tc.path, tc.cap, tc.n)
+ tc.test(t, sys.ops, []Op{
+ &Tmpfile{nil, tc.path, tc.n, nil},
+ }, "CopyFile")
+ })
+ }
+}
+
+func TestLink(t *testing.T) {
+ testCases := []struct {
+ dst, src string
+ }{
+ {"/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
+ {"/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
+ }
+ for _, tc := range testCases {
+ t.Run("link file "+tc.dst+" from "+tc.src, func(t *testing.T) {
+ sys := New(150)
+ sys.Link(tc.src, tc.dst)
+ (&tcOp{Process, tc.src}).test(t, sys.ops, []Op{
+ &Hardlink{Process, tc.dst, tc.src},
+ }, "Link")
+ })
+ }
+}
+
+func TestLinkFileType(t *testing.T) {
+ testCases := []struct {
+ tcOp
+ dst string
+ }{
+ {tcOp{User, "/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
+ {tcOp{Process, "/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
+ }
+ for _, tc := range testCases {
+ t.Run("link file "+tc.dst+" from "+tc.path+" with type "+TypeString(tc.et), func(t *testing.T) {
+ sys := New(150)
+ sys.LinkFileType(tc.et, tc.path, tc.dst)
+ tc.test(t, sys.ops, []Op{
+ &Hardlink{tc.et, tc.dst, tc.path},
+ }, "LinkFileType")
+ })
+ }
+}
+
+func TestTmpfile_String(t *testing.T) {
+ testCases := []struct {
+ src string
+ n int64
+ want string
+ }{
+ {"/home/ophestra/xdg/config/pulse/cookie", 256,
+ `up to 256 bytes from "/home/ophestra/xdg/config/pulse/cookie"`},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.want, func(t *testing.T) {
+ if got := (&Tmpfile{src: tc.src, n: tc.n}).String(); got != tc.want {
+ t.Errorf("String() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/system/wayland.go b/system/wayland.go
new file mode 100644
index 00000000..11f54d80
--- /dev/null
+++ b/system/wayland.go
@@ -0,0 +1,89 @@
+package system
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "git.gensokyo.uk/security/fortify/acl"
+ "git.gensokyo.uk/security/fortify/wl"
+)
+
+// Wayland sets up a wayland socket with a security context attached.
+func (sys *I) Wayland(syncFd **os.File, dst, src, appID, instanceID string) *I {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ sys.ops = append(sys.ops, &Wayland{syncFd, dst, src, appID, instanceID, wl.Conn{}})
+
+ return sys
+}
+
+type Wayland struct {
+ sync **os.File
+ dst, src string
+ appID, instanceID string
+
+ conn wl.Conn
+}
+
+func (w *Wayland) Type() Enablement { return Process }
+
+func (w *Wayland) apply(sys *I) error {
+ if w.sync == nil {
+ // this is a misuse of the API; do not return an error message
+ return errors.New("invalid sync")
+ }
+
+ // the Wayland op is not repeatable
+ if *w.sync != nil {
+ // this is a misuse of the API; do not return an error message
+ return errors.New("attempted to attach multiple wayland sockets")
+ }
+
+ if err := w.conn.Attach(w.src); err != nil {
+ // make console output less nasty
+ if errors.Is(err, os.ErrNotExist) {
+ err = os.ErrNotExist
+ }
+ return sys.wrapErrSuffix(err,
+ fmt.Sprintf("cannot attach to wayland on %q:", w.src))
+ } else {
+ sys.printf("wayland attached on %q", w.src)
+ }
+
+ if sp, err := w.conn.Bind(w.dst, w.appID, w.instanceID); err != nil {
+ return sys.wrapErrSuffix(err,
+ fmt.Sprintf("cannot bind to socket on %q:", w.dst))
+ } else {
+ *w.sync = sp
+ sys.printf("wayland listening on %q", w.dst)
+ return sys.wrapErrSuffix(errors.Join(os.Chmod(w.dst, 0), acl.UpdatePerm(w.dst, sys.uid, acl.Read, acl.Write, acl.Execute)),
+ fmt.Sprintf("cannot chmod socket on %q:", w.dst))
+ }
+}
+
+func (w *Wayland) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(w) {
+ sys.printf("removing wayland socket on %q", w.dst)
+ if err := os.Remove(w.dst); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+
+ sys.printf("detaching from wayland on %q", w.src)
+ return sys.wrapErrSuffix(w.conn.Close(),
+ fmt.Sprintf("cannot detach from wayland on %q:", w.src))
+ } else {
+ sys.printf("skipping wayland cleanup on %q", w.dst)
+ return nil
+ }
+}
+
+func (w *Wayland) Is(o Op) bool {
+ w0, ok := o.(*Wayland)
+ return ok && w.dst == w0.dst && w.src == w0.src &&
+ w.appID == w0.appID && w.instanceID == w0.instanceID
+}
+
+func (w *Wayland) Path() string { return w.dst }
+func (w *Wayland) String() string { return fmt.Sprintf("wayland socket at %q", w.dst) }
diff --git a/system/xhost.go b/system/xhost.go
new file mode 100644
index 00000000..bbc0a914
--- /dev/null
+++ b/system/xhost.go
@@ -0,0 +1,53 @@
+package system
+
+import (
+ "fmt"
+
+ "git.gensokyo.uk/security/fortify/xcb"
+)
+
+// ChangeHosts appends an X11 ChangeHosts command Op.
+func (sys *I) ChangeHosts(username string) *I {
+ sys.lock.Lock()
+ defer sys.lock.Unlock()
+
+ sys.ops = append(sys.ops, XHost(username))
+
+ return sys
+}
+
+type XHost string
+
+func (x XHost) Type() Enablement {
+ return EX11
+}
+
+func (x XHost) apply(sys *I) error {
+ sys.printf("inserting entry %s to X11", x)
+ return sys.wrapErrSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
+ fmt.Sprintf("cannot insert entry %s to X11:", x))
+}
+
+func (x XHost) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(x) {
+ sys.printf("deleting entry %s from X11", x)
+ return sys.wrapErrSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
+ fmt.Sprintf("cannot delete entry %s from X11:", x))
+ } else {
+ sys.printf("skipping entry %s in X11", x)
+ return nil
+ }
+}
+
+func (x XHost) Is(o Op) bool {
+ x0, ok := o.(XHost)
+ return ok && x == x0
+}
+
+func (x XHost) Path() string {
+ return string(x)
+}
+
+func (x XHost) String() string {
+ return string("SI:localuser:" + x)
+}
diff --git a/system/xhost_test.go b/system/xhost_test.go
new file mode 100644
index 00000000..4b364432
--- /dev/null
+++ b/system/xhost_test.go
@@ -0,0 +1,34 @@
+package system
+
+import (
+ "testing"
+)
+
+func TestChangeHosts(t *testing.T) {
+ testCases := []string{"chronos", "keyring", "cat", "kbd", "yonah"}
+ for _, tc := range testCases {
+ t.Run("append ChangeHosts operation for "+tc, func(t *testing.T) {
+ sys := New(150)
+ sys.ChangeHosts(tc)
+ (&tcOp{EX11, tc}).test(t, sys.ops, []Op{
+ XHost(tc),
+ }, "ChangeHosts")
+ })
+ }
+}
+
+func TestXHost_String(t *testing.T) {
+ testCases := []struct {
+ username string
+ want string
+ }{
+ {"chronos", "SI:localuser:chronos"},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.want, func(t *testing.T) {
+ if got := XHost(tc.username).String(); got != tc.want {
+ t.Errorf("String() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}