From 90cb01b2748550228d0bc179691a19ebb996fc36 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Mon, 17 Feb 2025 19:00:43 +0900 Subject: system: move out of internal Signed-off-by: Ophestra --- cmd/fpkg/bundle.go | 2 +- fst/config.go | 2 +- internal/app/app_nixos_test.go | 2 +- internal/app/app_pd_test.go | 2 +- internal/app/app_test.go | 2 +- internal/app/export_test.go | 2 +- internal/app/seal.go | 2 +- internal/app/share.go | 2 +- internal/app/start.go | 2 +- internal/app/system.go | 2 +- internal/system/acl.go | 65 --------------- internal/system/acl_test.go | 90 -------------------- internal/system/dbus.go | 148 -------------------------------- internal/system/enablement.go | 68 --------------- internal/system/link.go | 47 ----------- internal/system/mkdir.go | 86 ------------------- internal/system/mkdir_test.go | 73 ---------------- internal/system/op.go | 162 ------------------------------------ internal/system/op_internal_test.go | 79 ------------------ internal/system/op_test.go | 129 ---------------------------- internal/system/tmpfiles.go | 76 ----------------- internal/system/tmpfiles_test.go | 81 ------------------ internal/system/wayland.go | 89 -------------------- internal/system/xhost.go | 53 ------------ internal/system/xhost_test.go | 34 -------- main.go | 2 +- system/acl.go | 65 +++++++++++++++ system/acl_test.go | 90 ++++++++++++++++++++ system/dbus.go | 148 ++++++++++++++++++++++++++++++++ system/enablement.go | 68 +++++++++++++++ system/link.go | 47 +++++++++++ system/mkdir.go | 86 +++++++++++++++++++ system/mkdir_test.go | 73 ++++++++++++++++ system/op.go | 162 ++++++++++++++++++++++++++++++++++++ system/op_internal_test.go | 79 ++++++++++++++++++ system/op_test.go | 129 ++++++++++++++++++++++++++++ system/tmpfiles.go | 76 +++++++++++++++++ system/tmpfiles_test.go | 81 ++++++++++++++++++ system/wayland.go | 89 ++++++++++++++++++++ system/xhost.go | 53 ++++++++++++ system/xhost_test.go | 34 ++++++++ 41 files changed, 1291 insertions(+), 1291 deletions(-) delete mode 100644 internal/system/acl.go delete mode 100644 internal/system/acl_test.go delete mode 100644 internal/system/dbus.go delete mode 100644 internal/system/enablement.go delete mode 100644 internal/system/link.go delete mode 100644 internal/system/mkdir.go delete mode 100644 internal/system/mkdir_test.go delete mode 100644 internal/system/op.go delete mode 100644 internal/system/op_internal_test.go delete mode 100644 internal/system/op_test.go delete mode 100644 internal/system/tmpfiles.go delete mode 100644 internal/system/tmpfiles_test.go delete mode 100644 internal/system/wayland.go delete mode 100644 internal/system/xhost.go delete mode 100644 internal/system/xhost_test.go create mode 100644 system/acl.go create mode 100644 system/acl_test.go create mode 100644 system/dbus.go create mode 100644 system/enablement.go create mode 100644 system/link.go create mode 100644 system/mkdir.go create mode 100644 system/mkdir_test.go create mode 100644 system/op.go create mode 100644 system/op_internal_test.go create mode 100644 system/op_test.go create mode 100644 system/tmpfiles.go create mode 100644 system/tmpfiles_test.go create mode 100644 system/wayland.go create mode 100644 system/xhost.go create mode 100644 system/xhost_test.go diff --git a/cmd/fpkg/bundle.go b/cmd/fpkg/bundle.go index 5692d5cb..d1641168 100644 --- a/cmd/fpkg/bundle.go +++ b/cmd/fpkg/bundle.go @@ -6,7 +6,7 @@ import ( "os" "git.gensokyo.uk/security/fortify/dbus" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) type bundleInfo struct { diff --git a/fst/config.go b/fst/config.go index 9a03e499..89107798 100644 --- a/fst/config.go +++ b/fst/config.go @@ -3,7 +3,7 @@ package fst import ( "git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/helper/bwrap" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) const Tmp = "/.fortify" diff --git a/internal/app/app_nixos_test.go b/internal/app/app_nixos_test.go index 8923e9be..02d9aa27 100644 --- a/internal/app/app_nixos_test.go +++ b/internal/app/app_nixos_test.go @@ -5,7 +5,7 @@ import ( "git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/helper/bwrap" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) var testCasesNixos = []sealTestCase{ diff --git a/internal/app/app_pd_test.go b/internal/app/app_pd_test.go index b5cf46bf..fb74ae10 100644 --- a/internal/app/app_pd_test.go +++ b/internal/app/app_pd_test.go @@ -7,7 +7,7 @@ import ( "git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/helper/bwrap" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) var testCasesPd = []sealTestCase{ diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 25691ffe..46409446 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -11,7 +11,7 @@ import ( "git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/linux" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) type sealTestCase struct { diff --git a/internal/app/export_test.go b/internal/app/export_test.go index b7965190..8581d1c7 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -4,7 +4,7 @@ import ( "git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/internal/linux" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) func NewWithID(id fst.ID, os linux.System) App { diff --git a/internal/app/seal.go b/internal/app/seal.go index 4cd31298..6ad7edb3 100644 --- a/internal/app/seal.go +++ b/internal/app/seal.go @@ -19,7 +19,7 @@ import ( "git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/state" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) var ( diff --git a/internal/app/share.go b/internal/app/share.go index beed4f0e..f3c6b5b7 100644 --- a/internal/app/share.go +++ b/internal/app/share.go @@ -12,7 +12,7 @@ import ( "git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/linux" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/wl" ) diff --git a/internal/app/start.go b/internal/app/start.go index 8390b6be..33eaf869 100644 --- a/internal/app/start.go +++ b/internal/app/start.go @@ -14,7 +14,7 @@ import ( "git.gensokyo.uk/security/fortify/internal/app/shim" "git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/state" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) const shimSetupTimeout = 5 * time.Second diff --git a/internal/app/system.go b/internal/app/system.go index f11efc86..ccc5293f 100644 --- a/internal/app/system.go +++ b/internal/app/system.go @@ -4,7 +4,7 @@ import ( "os" "git.gensokyo.uk/security/fortify/helper/bwrap" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) // appSealSys encapsulates app seal behaviour with OS interactions diff --git a/internal/system/acl.go b/internal/system/acl.go deleted file mode 100644 index 840c3971..00000000 --- a/internal/system/acl.go +++ /dev/null @@ -1,65 +0,0 @@ -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/internal/system/acl_test.go b/internal/system/acl_test.go deleted file mode 100644 index ed154ad1..00000000 --- a/internal/system/acl_test.go +++ /dev/null @@ -1,90 +0,0 @@ -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/internal/system/dbus.go b/internal/system/dbus.go deleted file mode 100644 index cbb127ed..00000000 --- a/internal/system/dbus.go +++ /dev/null @@ -1,148 +0,0 @@ -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/internal/system/enablement.go b/internal/system/enablement.go deleted file mode 100644 index f0911d54..00000000 --- a/internal/system/enablement.go +++ /dev/null @@ -1,68 +0,0 @@ -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 "" - } - 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/internal/system/link.go b/internal/system/link.go deleted file mode 100644 index 4182d560..00000000 --- a/internal/system/link.go +++ /dev/null @@ -1,47 +0,0 @@ -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/internal/system/mkdir.go b/internal/system/mkdir.go deleted file mode 100644 index 524289e5..00000000 --- a/internal/system/mkdir.go +++ /dev/null @@ -1,86 +0,0 @@ -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/internal/system/mkdir_test.go b/internal/system/mkdir_test.go deleted file mode 100644 index 20826940..00000000 --- a/internal/system/mkdir_test.go +++ /dev/null @@ -1,73 +0,0 @@ -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/internal/system/op.go b/internal/system/op.go deleted file mode 100644 index 0e421aec..00000000 --- a/internal/system/op.go +++ /dev/null @@ -1,162 +0,0 @@ -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/internal/system/op_internal_test.go b/internal/system/op_internal_test.go deleted file mode 100644 index 7ec4f174..00000000 --- a/internal/system/op_internal_test.go +++ /dev/null @@ -1,79 +0,0 @@ -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/internal/system/op_test.go b/internal/system/op_test.go deleted file mode 100644 index 3e8c4d2d..00000000 --- a/internal/system/op_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package system_test - -import ( - "strconv" - "testing" - - "git.gensokyo.uk/security/fortify/internal/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/internal/system/tmpfiles.go b/internal/system/tmpfiles.go deleted file mode 100644 index 636943bc..00000000 --- a/internal/system/tmpfiles.go +++ /dev/null @@ -1,76 +0,0 @@ -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/internal/system/tmpfiles_test.go b/internal/system/tmpfiles_test.go deleted file mode 100644 index c7402f59..00000000 --- a/internal/system/tmpfiles_test.go +++ /dev/null @@ -1,81 +0,0 @@ -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/internal/system/wayland.go b/internal/system/wayland.go deleted file mode 100644 index 11f54d80..00000000 --- a/internal/system/wayland.go +++ /dev/null @@ -1,89 +0,0 @@ -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/internal/system/xhost.go b/internal/system/xhost.go deleted file mode 100644 index bbc0a914..00000000 --- a/internal/system/xhost.go +++ /dev/null @@ -1,53 +0,0 @@ -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/internal/system/xhost_test.go b/internal/system/xhost_test.go deleted file mode 100644 index 4b364432..00000000 --- a/internal/system/xhost_test.go +++ /dev/null @@ -1,34 +0,0 @@ -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) - } - }) - } -} diff --git a/main.go b/main.go index 9883afc6..ca3b9184 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ import ( "git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/state" - "git.gensokyo.uk/security/fortify/internal/system" + "git.gensokyo.uk/security/fortify/system" ) var ( 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 "" + } + 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) + } + }) + } +} -- cgit v1.3.1