aboutsummaryrefslogtreecommitdiffhomepage
path: root/system
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-07-02 21:52:07 +0900
committerOphestra <cat@gensokyo.uk>2025-07-02 21:52:07 +0900
commit82561d62b66f17c05604e87f18187bb3a91f00d2 (patch)
treec1543f85b4458b7d5cb2cf7b1baa9dee318debfe /system
parenteec021cc4b4eb42bc9c8311755826828bfba1996 (diff)
system: move system access packages
These packages loosely belong in the "system" package and "system" provides high level wrappers for all of them. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'system')
-rw-r--r--system/acl.go2
-rw-r--r--system/acl/acl.go36
-rw-r--r--system/acl/acl_getfacl_test.go156
-rw-r--r--system/acl/acl_test.go125
-rw-r--r--system/acl/libacl-helper.c71
-rw-r--r--system/acl/libacl-helper.h4
-rw-r--r--system/acl/perms.go18
-rw-r--r--system/acl_test.go2
-rw-r--r--system/dbus.go2
-rw-r--r--system/dbus/address.go186
-rw-r--r--system/dbus/address_escape_test.go55
-rw-r--r--system/dbus/address_test.go119
-rw-r--r--system/dbus/config.go162
-rw-r--r--system/dbus/config_test.go159
-rw-r--r--system/dbus/dbus.go40
-rw-r--r--system/dbus/dbus_test.go213
-rw-r--r--system/dbus/export_test.go13
-rw-r--r--system/dbus/proc.go189
-rw-r--r--system/dbus/proxy.go117
-rw-r--r--system/dbus/samples_test.go228
-rw-r--r--system/dbus/stub_test.go9
-rw-r--r--system/dbus/testdata/dev.vencord.Vesktop.json18
-rw-r--r--system/dbus/testdata/org.chromium.Chromium+.json9
-rw-r--r--system/dbus/testdata/org.chromium.Chromium.json24
-rw-r--r--system/dbus/testdata/uk.gensokyo.CrashTestDummy.json21
-rw-r--r--system/wayland.go8
-rw-r--r--system/wayland/conn.go121
-rw-r--r--system/wayland/consts.go15
-rw-r--r--system/wayland/security-context-v1-protocol.c74
-rw-r--r--system/wayland/security-context-v1-protocol.h392
-rw-r--r--system/wayland/wayland-client-helper.c96
-rw-r--r--system/wayland/wayland-client-helper.h4
-rw-r--r--system/wayland/wayland.go36
33 files changed, 2717 insertions, 7 deletions
diff --git a/system/acl.go b/system/acl.go
index fe44ea5b..2efd7257 100644
--- a/system/acl.go
+++ b/system/acl.go
@@ -6,7 +6,7 @@ import (
"os"
"slices"
- "git.gensokyo.uk/security/hakurei/acl"
+ "git.gensokyo.uk/security/hakurei/system/acl"
)
// UpdatePerm appends an ephemeral acl update Op.
diff --git a/system/acl/acl.go b/system/acl/acl.go
new file mode 100644
index 00000000..bc590db2
--- /dev/null
+++ b/system/acl/acl.go
@@ -0,0 +1,36 @@
+// Package acl implements simple ACL manipulation via libacl.
+package acl
+
+/*
+#cgo linux pkg-config: --static libacl
+
+#include "libacl-helper.h"
+*/
+import "C"
+
+type Perm C.acl_perm_t
+
+const (
+ Read Perm = C.ACL_READ
+ Write Perm = C.ACL_WRITE
+ Execute Perm = C.ACL_EXECUTE
+)
+
+// Update replaces ACL_USER entry with qualifier uid.
+func Update(name string, uid int, perms ...Perm) error {
+ var p *Perm
+ if len(perms) > 0 {
+ p = &perms[0]
+ }
+
+ r, err := C.hakurei_acl_update_file_by_uid(
+ C.CString(name),
+ C.uid_t(uid),
+ (*C.acl_perm_t)(p),
+ C.size_t(len(perms)),
+ )
+ if r == 0 {
+ return nil
+ }
+ return err
+}
diff --git a/system/acl/acl_getfacl_test.go b/system/acl/acl_getfacl_test.go
new file mode 100644
index 00000000..c20fade7
--- /dev/null
+++ b/system/acl/acl_getfacl_test.go
@@ -0,0 +1,156 @@
+package acl_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "strconv"
+)
+
+type (
+ getFAclInvocation struct {
+ cmd *exec.Cmd
+ val []*getFAclResp
+ pe []error
+ }
+
+ getFAclResp struct {
+ typ fAclType
+ cred int32
+ val fAclPerm
+
+ raw []byte
+ }
+
+ fAclPerm uintptr
+ fAclType uint8
+)
+
+const fAclBufSize = 16
+
+const (
+ fAclPermRead fAclPerm = 1 << iota
+ fAclPermWrite
+ fAclPermExecute
+)
+
+const (
+ fAclTypeUser fAclType = iota
+ fAclTypeGroup
+ fAclTypeMask
+ fAclTypeOther
+)
+
+func (c *getFAclInvocation) run(name string) error {
+ if c.cmd != nil {
+ panic("attempted to run twice")
+ }
+
+ c.cmd = exec.Command("getfacl", "--omit-header", "--absolute-names", "--numeric", name)
+
+ scanErr := make(chan error, 1)
+ if p, err := c.cmd.StdoutPipe(); err != nil {
+ return err
+ } else {
+ go c.parse(p, scanErr)
+ }
+
+ if err := c.cmd.Start(); err != nil {
+ return err
+ }
+
+ return errors.Join(<-scanErr, c.cmd.Wait())
+}
+
+func (c *getFAclInvocation) parse(pipe io.Reader, scanErr chan error) {
+ c.val = make([]*getFAclResp, 0, 4+fAclBufSize)
+
+ s := bufio.NewScanner(pipe)
+ for s.Scan() {
+ fields := bytes.SplitN(s.Bytes(), []byte{':'}, 3)
+ if len(fields) != 3 {
+ continue
+ }
+
+ resp := getFAclResp{}
+
+ switch string(fields[0]) {
+ case "user":
+ resp.typ = fAclTypeUser
+ case "group":
+ resp.typ = fAclTypeGroup
+ case "mask":
+ resp.typ = fAclTypeMask
+ case "other":
+ resp.typ = fAclTypeOther
+ default:
+ c.pe = append(c.pe, fmt.Errorf("unknown type %s", string(fields[0])))
+ continue
+ }
+
+ if len(fields[1]) == 0 {
+ resp.cred = -1
+ } else {
+ if cred, err := strconv.Atoi(string(fields[1])); err != nil {
+ c.pe = append(c.pe, err)
+ continue
+ } else {
+ resp.cred = int32(cred)
+ if resp.cred < 0 {
+ c.pe = append(c.pe, fmt.Errorf("credential %d out of range", resp.cred))
+ continue
+ }
+ }
+ }
+
+ if len(fields[2]) != 3 {
+ c.pe = append(c.pe, fmt.Errorf("invalid perm length %d", len(fields[2])))
+ continue
+ } else {
+ switch fields[2][0] {
+ case 'r':
+ resp.val |= fAclPermRead
+ case '-':
+ default:
+ c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][0]))
+ continue
+ }
+ switch fields[2][1] {
+ case 'w':
+ resp.val |= fAclPermWrite
+ case '-':
+ default:
+ c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][1]))
+ continue
+ }
+ switch fields[2][2] {
+ case 'x':
+ resp.val |= fAclPermExecute
+ case '-':
+ default:
+ c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][2]))
+ continue
+ }
+ }
+
+ resp.raw = make([]byte, len(s.Bytes()))
+ copy(resp.raw, s.Bytes())
+ c.val = append(c.val, &resp)
+ }
+ scanErr <- s.Err()
+}
+
+func (r *getFAclResp) String() string {
+ if r.raw != nil && len(r.raw) > 0 {
+ return string(r.raw)
+ }
+
+ return "(user-initialised resp value)"
+}
+
+func (r *getFAclResp) equals(typ fAclType, cred int32, val fAclPerm) bool {
+ return r.typ == typ && r.cred == cred && r.val == val
+}
diff --git a/system/acl/acl_test.go b/system/acl/acl_test.go
new file mode 100644
index 00000000..bfb355e0
--- /dev/null
+++ b/system/acl/acl_test.go
@@ -0,0 +1,125 @@
+package acl_test
+
+import (
+ "errors"
+ "os"
+ "path"
+ "reflect"
+ "testing"
+
+ "git.gensokyo.uk/security/hakurei/system/acl"
+)
+
+const testFileName = "acl.test"
+
+var (
+ uid = os.Geteuid()
+ cred = int32(os.Geteuid())
+)
+
+func TestUpdatePerm(t *testing.T) {
+ if os.Getenv("GO_TEST_SKIP_ACL") == "1" {
+ t.Log("acl test skipped")
+ t.SkipNow()
+ }
+
+ testFilePath := path.Join(t.TempDir(), testFileName)
+
+ if f, err := os.Create(testFilePath); err != nil {
+ t.Fatalf("Create: error = %v", err)
+ } else {
+ if err = f.Close(); err != nil {
+ t.Fatalf("Close: error = %v", err)
+ }
+ }
+ defer func() {
+ if err := os.Remove(testFilePath); err != nil {
+ t.Fatalf("Remove: error = %v", err)
+ }
+ }()
+
+ cur := getfacl(t, testFilePath)
+
+ t.Run("default entry count", func(t *testing.T) {
+ if len(cur) != 3 {
+ t.Fatalf("unexpected test file acl length %d", len(cur))
+ }
+ })
+
+ t.Run("default clear mask", func(t *testing.T) {
+ if err := acl.Update(testFilePath, uid); err != nil {
+ t.Fatalf("UpdatePerm: error = %v", err)
+ }
+ if cur = getfacl(t, testFilePath); len(cur) != 4 {
+ t.Fatalf("UpdatePerm: %v", cur)
+ }
+ })
+
+ t.Run("default clear consistency", func(t *testing.T) {
+ if err := acl.Update(testFilePath, uid); err != nil {
+ t.Fatalf("UpdatePerm: error = %v", err)
+ }
+ if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) {
+ t.Fatalf("UpdatePerm: %v, want %v", val, cur)
+ }
+ })
+
+ testUpdate(t, testFilePath, "r--", cur, fAclPermRead, acl.Read)
+ testUpdate(t, testFilePath, "-w-", cur, fAclPermWrite, acl.Write)
+ testUpdate(t, testFilePath, "--x", cur, fAclPermExecute, acl.Execute)
+ testUpdate(t, testFilePath, "-wx", cur, fAclPermWrite|fAclPermExecute, acl.Write, acl.Execute)
+ testUpdate(t, testFilePath, "r-x", cur, fAclPermRead|fAclPermExecute, acl.Read, acl.Execute)
+ testUpdate(t, testFilePath, "rw-", cur, fAclPermRead|fAclPermWrite, acl.Read, acl.Write)
+ testUpdate(t, testFilePath, "rwx", cur, fAclPermRead|fAclPermWrite|fAclPermExecute, acl.Read, acl.Write, acl.Execute)
+}
+
+func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) {
+ t.Run(name, func(t *testing.T) {
+ t.Cleanup(func() {
+ if err := acl.Update(testFilePath, uid); err != nil {
+ t.Fatalf("UpdatePerm: error = %v", err)
+ }
+ if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) {
+ t.Fatalf("UpdatePerm: %v, want %v", v, cur)
+ }
+ })
+
+ if err := acl.Update(testFilePath, uid, perms...); err != nil {
+ t.Fatalf("UpdatePerm: error = %v", err)
+ }
+ r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred)
+ if r == nil {
+ t.Fatalf("UpdatePerm did not add an ACL entry")
+ }
+ if !r.equals(fAclTypeUser, cred, val) {
+ t.Fatalf("UpdatePerm(%s) = %s", name, r)
+ }
+ })
+}
+
+func getfacl(t *testing.T, name string) []*getFAclResp {
+ c := new(getFAclInvocation)
+ if err := c.run(name); err != nil {
+ t.Fatalf("getfacl: error = %v", err)
+ }
+ if len(c.pe) != 0 {
+ t.Errorf("errors encountered parsing getfacl output\n%s", errors.Join(c.pe...).Error())
+ }
+ return c.val
+}
+
+func respByCred(v []*getFAclResp, typ fAclType, cred int32) *getFAclResp {
+ j := -1
+ for i, r := range v {
+ if r.typ == typ && r.cred == cred {
+ if j != -1 {
+ panic("invalid acl")
+ }
+ j = i
+ }
+ }
+ if j == -1 {
+ return nil
+ }
+ return v[j]
+}
diff --git a/system/acl/libacl-helper.c b/system/acl/libacl-helper.c
new file mode 100644
index 00000000..905cfa52
--- /dev/null
+++ b/system/acl/libacl-helper.c
@@ -0,0 +1,71 @@
+#include "libacl-helper.h"
+#include <acl/libacl.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <sys/acl.h>
+
+int hakurei_acl_update_file_by_uid(const char *path_p, uid_t uid,
+ acl_perm_t *perms, size_t plen) {
+ int ret = -1;
+ bool v;
+ int i;
+ acl_t acl;
+ acl_entry_t entry;
+ acl_tag_t tag_type;
+ void *qualifier_p;
+ acl_permset_t permset;
+
+ acl = acl_get_file(path_p, ACL_TYPE_ACCESS);
+ if (acl == NULL)
+ goto out;
+
+ // prune entries by uid
+ for (i = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry); i == 1;
+ i = acl_get_entry(acl, ACL_NEXT_ENTRY, &entry)) {
+ if (acl_get_tag_type(entry, &tag_type) != 0)
+ return -1;
+ if (tag_type != ACL_USER)
+ continue;
+
+ qualifier_p = acl_get_qualifier(entry);
+ if (qualifier_p == NULL)
+ return -1;
+ v = *(uid_t *)qualifier_p == uid;
+ acl_free(qualifier_p);
+
+ if (!v)
+ continue;
+
+ acl_delete_entry(acl, entry);
+ }
+
+ if (plen == 0)
+ goto set;
+
+ if (acl_create_entry(&acl, &entry) != 0)
+ goto out;
+ if (acl_get_permset(entry, &permset) != 0)
+ goto out;
+ for (i = 0; i < plen; i++) {
+ if (acl_add_perm(permset, perms[i]) != 0)
+ goto out;
+ }
+ if (acl_set_tag_type(entry, ACL_USER) != 0)
+ goto out;
+ if (acl_set_qualifier(entry, (void *)&uid) != 0)
+ goto out;
+
+set:
+ if (acl_calc_mask(&acl) != 0)
+ goto out;
+ if (acl_valid(acl) != 0)
+ goto out;
+ if (acl_set_file(path_p, ACL_TYPE_ACCESS, acl) == 0)
+ ret = 0;
+
+out:
+ free((void *)path_p);
+ if (acl != NULL)
+ acl_free((void *)acl);
+ return ret;
+}
diff --git a/system/acl/libacl-helper.h b/system/acl/libacl-helper.h
new file mode 100644
index 00000000..b86eb170
--- /dev/null
+++ b/system/acl/libacl-helper.h
@@ -0,0 +1,4 @@
+#include <sys/acl.h>
+
+int hakurei_acl_update_file_by_uid(const char *path_p, uid_t uid,
+ acl_perm_t *perms, size_t plen);
diff --git a/system/acl/perms.go b/system/acl/perms.go
new file mode 100644
index 00000000..fbc99e82
--- /dev/null
+++ b/system/acl/perms.go
@@ -0,0 +1,18 @@
+package acl
+
+type Perms []Perm
+
+func (ps Perms) String() string {
+ var s = []byte("---")
+ for _, p := range ps {
+ switch p {
+ case Read:
+ s[0] = 'r'
+ case Write:
+ s[1] = 'w'
+ case Execute:
+ s[2] = 'x'
+ }
+ }
+ return string(s)
+}
diff --git a/system/acl_test.go b/system/acl_test.go
index 5dcccd1d..f8f28323 100644
--- a/system/acl_test.go
+++ b/system/acl_test.go
@@ -3,7 +3,7 @@ package system
import (
"testing"
- "git.gensokyo.uk/security/hakurei/acl"
+ "git.gensokyo.uk/security/hakurei/system/acl"
)
func TestUpdatePerm(t *testing.T) {
diff --git a/system/dbus.go b/system/dbus.go
index f0532c4a..8ca20e70 100644
--- a/system/dbus.go
+++ b/system/dbus.go
@@ -9,7 +9,7 @@ import (
"sync"
"syscall"
- "git.gensokyo.uk/security/hakurei/dbus"
+ "git.gensokyo.uk/security/hakurei/system/dbus"
)
var (
diff --git a/system/dbus/address.go b/system/dbus/address.go
new file mode 100644
index 00000000..bec49e62
--- /dev/null
+++ b/system/dbus/address.go
@@ -0,0 +1,186 @@
+package dbus
+
+import (
+ "bytes"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "slices"
+)
+
+type AddrEntry struct {
+ Method string `json:"method"`
+ Values [][2]string `json:"values"`
+}
+
+// Parse parses D-Bus address according to
+// https://dbus.freedesktop.org/doc/dbus-specification.html#addresses
+func Parse(addr []byte) ([]AddrEntry, error) {
+ // Look for a semicolon
+ address := bytes.Split(bytes.TrimSuffix(addr, []byte{';'}), []byte{';'})
+
+ // Allocate for entries
+ v := make([]AddrEntry, len(address))
+
+ for i, s := range address {
+ var pairs [][]byte
+
+ // Look for the colon :
+ if method, list, ok := bytes.Cut(s, []byte{':'}); !ok {
+ return v, &BadAddressError{ErrNoColon, i, s, -1, nil}
+ } else {
+ pairs = bytes.Split(list, []byte{','})
+ v[i].Method = string(method)
+ v[i].Values = make([][2]string, len(pairs))
+ }
+
+ for j, pair := range pairs {
+ key, value, ok := bytes.Cut(pair, []byte{'='})
+ if !ok {
+ return v, &BadAddressError{ErrBadPairSep, i, s, j, pair}
+ }
+ if len(key) == 0 {
+ return v, &BadAddressError{ErrBadPairKey, i, s, j, pair}
+ }
+ if len(value) == 0 {
+ return v, &BadAddressError{ErrBadPairVal, i, s, j, pair}
+ }
+ v[i].Values[j][0] = string(key)
+
+ if val, errno := unescapeValue(value); errno != errSuccess {
+ return v, &BadAddressError{errno, i, s, j, pair}
+ } else {
+ v[i].Values[j][1] = string(val)
+ }
+ }
+ }
+
+ return v, nil
+}
+
+func unescapeValue(v []byte) (val []byte, errno ParseError) {
+ if l := len(v) - (bytes.Count(v, []byte{'%'}) * 2); l < 0 {
+ errno = ErrBadValLength
+ return
+ } else {
+ val = make([]byte, l)
+ }
+
+ var i, skip int
+ for iu, b := range v {
+ if skip > 0 {
+ skip--
+ continue
+ }
+
+ if ib := bytes.IndexByte([]byte("-_/.\\*"), b); ib != -1 { // - // _/.\*
+ goto opt
+ } else if b >= '0' && b <= '9' { // 0-9
+ goto opt
+ } else if b >= 'A' && b <= 'Z' { // A-Z
+ goto opt
+ } else if b >= 'a' && b <= 'z' { // a-z
+ goto opt
+ }
+
+ if b != '%' {
+ errno = ErrBadValByte
+ break
+ }
+
+ skip += 2
+ if iu+2 >= len(v) {
+ errno = ErrBadValHexLength
+ break
+ }
+ if c, err := hex.Decode(val[i:i+1], v[iu+1:iu+3]); err != nil {
+ if errors.As(err, new(hex.InvalidByteError)) {
+ errno = ErrBadValHexByte
+ break
+ }
+ // unreachable
+ panic(err.Error())
+ } else if c != 1 {
+ // unreachable
+ panic(fmt.Sprintf("invalid decode length %d", c))
+ }
+ i++
+ continue
+
+ opt:
+ val[i] = b
+ i++
+ }
+
+ return
+}
+
+type ParseError uint8
+
+func (e ParseError) Error() string {
+ switch e {
+ case errSuccess:
+ panic("attempted to return success as error")
+ case ErrNoColon:
+ return "address does not contain a colon"
+ case ErrBadPairSep:
+ return "'=' character not found"
+ case ErrBadPairKey:
+ return "'=' character has no key preceding it"
+ case ErrBadPairVal:
+ return "'=' character has no value following it"
+ case ErrBadValLength:
+ return "unescaped value has impossible length"
+ case ErrBadValByte:
+ return "in D-Bus address, characters other than [-0-9A-Za-z_/.\\*] should have been escaped"
+ case ErrBadValHexLength:
+ return "in D-Bus address, percent character was not followed by two hex digits"
+ case ErrBadValHexByte:
+ return "in D-Bus address, percent character was followed by characters other than hex digits"
+
+ default:
+ return fmt.Sprintf("parse error %d", e)
+ }
+}
+
+const (
+ errSuccess ParseError = iota
+ ErrNoColon
+ ErrBadPairSep
+ ErrBadPairKey
+ ErrBadPairVal
+ ErrBadValLength
+ ErrBadValByte
+ ErrBadValHexLength
+ ErrBadValHexByte
+)
+
+type BadAddressError struct {
+ // error type
+ Type ParseError
+
+ // bad entry position
+ EntryPos int
+ // bad entry value
+ EntryVal []byte
+
+ // bad pair position
+ PairPos int
+ // bad pair value
+ PairVal []byte
+}
+
+func (a *BadAddressError) Is(err error) bool {
+ var b *BadAddressError
+ return errors.As(err, &b) && a.Type == b.Type &&
+ a.EntryPos == b.EntryPos && slices.Equal(a.EntryVal, b.EntryVal) &&
+ a.PairPos == b.PairPos && slices.Equal(a.PairVal, b.PairVal)
+}
+
+func (a *BadAddressError) Error() string {
+ return a.Type.Error()
+}
+
+func (a *BadAddressError) Unwrap() error {
+ return a.Type
+}
diff --git a/system/dbus/address_escape_test.go b/system/dbus/address_escape_test.go
new file mode 100644
index 00000000..3ea2cd26
--- /dev/null
+++ b/system/dbus/address_escape_test.go
@@ -0,0 +1,55 @@
+package dbus
+
+import (
+ "testing"
+)
+
+func TestUnescapeValue(t *testing.T) {
+ testCases := []struct {
+ value string
+ want string
+ wantErr ParseError
+ }{
+ // upstream test cases
+ {value: "abcde", want: "abcde"},
+ {value: "", want: ""},
+ {value: "%20%20", want: " "},
+ {value: "%24", want: "$"},
+ {value: "%25", want: "%"},
+ {value: "abc%24", want: "abc$"},
+ {value: "%24abc", want: "$abc"},
+ {value: "abc%24abc", want: "abc$abc"},
+ {value: "/", want: "/"},
+ {value: "-", want: "-"},
+ {value: "_", want: "_"},
+ {value: "A", want: "A"},
+ {value: "I", want: "I"},
+ {value: "Z", want: "Z"},
+ {value: "a", want: "a"},
+ {value: "i", want: "i"},
+ {value: "z", want: "z"},
+ /* Bug: https://bugs.freedesktop.org/show_bug.cgi?id=53499 */
+ {value: "%c3%b6", want: "\xc3\xb6"},
+
+ {value: "%a", wantErr: ErrBadValHexLength},
+ {value: "%q", wantErr: ErrBadValHexLength},
+ {value: "%az", wantErr: ErrBadValHexByte},
+ {value: "%%", wantErr: ErrBadValLength},
+ {value: "%$$", wantErr: ErrBadValHexByte},
+ {value: "abc%a", wantErr: ErrBadValHexLength},
+ {value: "%axyz", wantErr: ErrBadValHexByte},
+ {value: "%", wantErr: ErrBadValLength},
+ {value: "$", wantErr: ErrBadValByte},
+ {value: " ", wantErr: ErrBadValByte},
+ }
+
+ for _, tc := range testCases {
+ t.Run("unescape "+tc.value, func(t *testing.T) {
+ if got, errno := unescapeValue([]byte(tc.value)); errno != tc.wantErr {
+ t.Errorf("unescapeValue() errno = %v, wantErr %v", errno, tc.wantErr)
+ } else if tc.wantErr == errSuccess && string(got) != tc.want {
+ t.Errorf("unescapeValue() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/system/dbus/address_test.go b/system/dbus/address_test.go
new file mode 100644
index 00000000..148bb092
--- /dev/null
+++ b/system/dbus/address_test.go
@@ -0,0 +1,119 @@
+package dbus_test
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+
+ "git.gensokyo.uk/security/hakurei/system/dbus"
+)
+
+func TestParse(t *testing.T) {
+ testCases := []struct {
+ name string
+ addr string
+ want []dbus.AddrEntry
+ wantErr error
+ }{
+ {
+ name: "simple session unix",
+ addr: "unix:path=/run/user/1971/bus",
+ want: []dbus.AddrEntry{{
+ Method: "unix",
+ Values: [][2]string{{"path", "/run/user/1971/bus"}},
+ }},
+ },
+ {
+ name: "simple upper escape",
+ addr: "debug:name=Test,cat=cute,escaped=%c3%b6",
+ want: []dbus.AddrEntry{{
+ Method: "debug",
+ Values: [][2]string{
+ {"name", "Test"},
+ {"cat", "cute"},
+ {"escaped", "\xc3\xb6"},
+ },
+ }},
+ },
+ {
+ name: "simple bad escape",
+ addr: "debug:name=%",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadValLength,
+ EntryPos: 0, EntryVal: []byte("debug:name=%"), PairPos: 0, PairVal: []byte("name=%")},
+ },
+
+ // upstream test cases
+ {
+ name: "full address success",
+ addr: "unix:path=/tmp/foo;debug:name=test,sliff=sloff;",
+ want: []dbus.AddrEntry{
+ {Method: "unix", Values: [][2]string{{"path", "/tmp/foo"}}},
+ {Method: "debug", Values: [][2]string{{"name", "test"}, {"sliff", "sloff"}}},
+ },
+ },
+ {
+ name: "empty address",
+ addr: "",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrNoColon,
+ EntryVal: []byte{}, PairPos: -1},
+ },
+ {
+ name: "no body",
+ addr: "foo",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrNoColon,
+ EntryPos: 0, EntryVal: []byte("foo"), PairPos: -1},
+ },
+ {
+ name: "no pair separator",
+ addr: "foo:bar",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadPairSep,
+ EntryPos: 0, EntryVal: []byte("foo:bar"), PairPos: 0, PairVal: []byte("bar")},
+ },
+ {
+ name: "no pair separator multi pair",
+ addr: "foo:bar,baz",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadPairSep,
+ EntryPos: 0, EntryVal: []byte("foo:bar,baz"), PairPos: 0, PairVal: []byte("bar")},
+ },
+ {
+ name: "no pair separator single valid pair",
+ addr: "foo:bar=foo,baz",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadPairSep,
+ EntryPos: 0, EntryVal: []byte("foo:bar=foo,baz"), PairPos: 1, PairVal: []byte("baz")},
+ },
+ {
+ name: "no body single valid address",
+ addr: "foo:bar=foo;baz",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrNoColon,
+ EntryPos: 1, EntryVal: []byte("baz"), PairPos: -1},
+ },
+ {
+ name: "no key",
+ addr: "foo:=foo",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadPairKey,
+ EntryPos: 0, EntryVal: []byte("foo:=foo"), PairPos: 0, PairVal: []byte("=foo")},
+ },
+ {
+ name: "no value",
+ addr: "foo:foo=",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadPairVal,
+ EntryPos: 0, EntryVal: []byte("foo:foo="), PairPos: 0, PairVal: []byte("foo=")},
+ },
+ {
+ name: "no pair separator single valid pair trailing",
+ addr: "foo:foo,bar=baz",
+ wantErr: &dbus.BadAddressError{Type: dbus.ErrBadPairSep,
+ EntryPos: 0, EntryVal: []byte("foo:foo,bar=baz"), PairPos: 0, PairVal: []byte("foo")},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got, err := dbus.Parse([]byte(tc.addr)); !errors.Is(err, tc.wantErr) {
+ t.Errorf("Parse() error = %v, wantErr %v", err, tc.wantErr)
+ } else if tc.wantErr == nil && !reflect.DeepEqual(got, tc.want) {
+ t.Errorf("Parse() = %#v, want %#v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/system/dbus/config.go b/system/dbus/config.go
new file mode 100644
index 00000000..3f66b2bb
--- /dev/null
+++ b/system/dbus/config.go
@@ -0,0 +1,162 @@
+package dbus
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "strings"
+)
+
+// ProxyPair is an upstream dbus address and a downstream socket path.
+type ProxyPair [2]string
+
+type Config struct {
+ // See set 'see' policy for NAME (--see=NAME)
+ See []string `json:"see"`
+ // Talk set 'talk' policy for NAME (--talk=NAME)
+ Talk []string `json:"talk"`
+ // Own set 'own' policy for NAME (--own=NAME)
+ Own []string `json:"own"`
+
+ // Call set RULE for calls on NAME (--call=NAME=RULE)
+ Call map[string]string `json:"call"`
+ // Broadcast set RULE for broadcasts from NAME (--broadcast=NAME=RULE)
+ Broadcast map[string]string `json:"broadcast"`
+
+ Log bool `json:"log,omitempty"`
+ Filter bool `json:"filter"`
+}
+
+func (c *Config) interfaces(yield func(string) bool) {
+ for _, iface := range c.See {
+ if !yield(iface) {
+ return
+ }
+ }
+ for _, iface := range c.Talk {
+ if !yield(iface) {
+ return
+ }
+ }
+ for _, iface := range c.Own {
+ if !yield(iface) {
+ return
+ }
+ }
+
+ for iface := range c.Call {
+ if !yield(iface) {
+ return
+ }
+ }
+ for iface := range c.Broadcast {
+ if !yield(iface) {
+ return
+ }
+ }
+}
+
+func (c *Config) checkInterfaces(segment string) error {
+ for iface := range c.interfaces {
+ /*
+ xdg-dbus-proxy fails without output when this condition is not met:
+ char *dot = strrchr (filter->interface, '.');
+ if (dot != NULL)
+ {
+ *dot = 0;
+ if (strcmp (dot + 1, "*") != 0)
+ filter->member = g_strdup (dot + 1);
+ }
+
+ trim ".*" since they are removed before searching for '.':
+ if (g_str_has_suffix (name, ".*"))
+ {
+ name[strlen (name) - 2] = 0;
+ wildcard = TRUE;
+ }
+ */
+ if strings.IndexByte(strings.TrimSuffix(iface, ".*"), '.') == -1 {
+ return &BadInterfaceError{iface, segment}
+ }
+ }
+ return nil
+}
+
+func (c *Config) Args(bus ProxyPair) (args []string) {
+ argc := 2 + len(c.See) + len(c.Talk) + len(c.Own) + len(c.Call) + len(c.Broadcast)
+ if c.Log {
+ argc++
+ }
+ if c.Filter {
+ argc++
+ }
+
+ args = make([]string, 0, argc)
+ args = append(args, bus[0], bus[1])
+ if c.Filter {
+ args = append(args, "--filter")
+ }
+ for _, name := range c.See {
+ args = append(args, "--see="+name)
+ }
+ for _, name := range c.Talk {
+ args = append(args, "--talk="+name)
+ }
+ for _, name := range c.Own {
+ args = append(args, "--own="+name)
+ }
+ for name, rule := range c.Call {
+ args = append(args, "--call="+name+"="+rule)
+ }
+ for name, rule := range c.Broadcast {
+ args = append(args, "--broadcast="+name+"="+rule)
+ }
+ if c.Log {
+ args = append(args, "--log")
+ }
+
+ return
+}
+
+func (c *Config) Load(r io.Reader) error { return json.NewDecoder(r).Decode(&c) }
+
+// NewConfigFromFile opens the target config file at path and parses its contents into *Config.
+func NewConfigFromFile(path string) (*Config, error) {
+ if f, err := os.Open(path); err != nil {
+ return nil, err
+ } else {
+ c := new(Config)
+ err1 := c.Load(f)
+ err = f.Close()
+
+ return c, errors.Join(err1, err)
+ }
+}
+
+// NewConfig returns a reference to a Config struct with optional defaults.
+// If id is an empty string own defaults are omitted.
+func NewConfig(id string, defaults, mpris bool) (c *Config) {
+ c = &Config{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+
+ Filter: true,
+ }
+
+ if defaults {
+ c.Talk = []string{"org.freedesktop.DBus", "org.freedesktop.Notifications"}
+
+ c.Call["org.freedesktop.portal.*"] = "*"
+ c.Broadcast["org.freedesktop.portal.*"] = "@/org/freedesktop/portal/*"
+
+ if id != "" {
+ c.Own = []string{id + ".*"}
+ if mpris {
+ c.Own = append(c.Own, "org.mpris.MediaPlayer2."+id+".*")
+ }
+ }
+ }
+
+ return
+}
diff --git a/system/dbus/config_test.go b/system/dbus/config_test.go
new file mode 100644
index 00000000..f79c4c50
--- /dev/null
+++ b/system/dbus/config_test.go
@@ -0,0 +1,159 @@
+package dbus_test
+
+import (
+ "errors"
+ "os"
+ "path"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+
+ "git.gensokyo.uk/security/hakurei/system/dbus"
+)
+
+func TestConfig_Args(t *testing.T) {
+ for _, tc := range makeTestCases() {
+ if tc.wantErr {
+ // args does not check for nulls
+ continue
+ }
+
+ t.Run("build arguments for "+tc.id, func(t *testing.T) {
+ if got := tc.c.Args(tc.bus); !slices.Equal(got, tc.want) {
+ t.Errorf("Args(%q) = %v, want %v",
+ tc.bus,
+ got, tc.want)
+ }
+ })
+ }
+}
+
+func TestNewConfigFromFile(t *testing.T) {
+ for _, tc := range makeTestCases() {
+ name := new(strings.Builder)
+ name.WriteString("parse configuration file for application ")
+ name.WriteString(tc.id)
+ if tc.wantErr {
+ name.WriteString(" with unexpected results")
+ }
+
+ samplePath := path.Join("testdata", tc.id+".json")
+
+ t.Run(name.String(), func(t *testing.T) {
+ got, err := dbus.NewConfigFromFile(samplePath)
+ if errors.Is(err, os.ErrNotExist) != tc.wantErrF {
+ t.Errorf("NewConfigFromFile(%q) error = %v, wantErrF %v",
+ samplePath,
+ err, tc.wantErrF)
+ return
+ }
+
+ if tc.wantErrF {
+ return
+ }
+
+ if !tc.wantErr && !reflect.DeepEqual(got, tc.c) {
+ t.Errorf("NewConfigFromFile(%q) got = %v, want %v",
+ samplePath,
+ got, tc.c)
+ }
+ if tc.wantErr && reflect.DeepEqual(got, tc.c) {
+ t.Errorf("NewConfigFromFile(%q) got = %v, wantErr %v",
+ samplePath,
+ got, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestNewConfig(t *testing.T) {
+ ids := [...]string{"org.chromium.Chromium", "dev.vencord.Vesktop"}
+
+ type newTestCase struct {
+ id string
+ args [2]bool
+ want *dbus.Config
+ }
+
+ // populate tests from IDs in generic tests
+ tcs := make([]newTestCase, 0, (len(ids)+1)*4)
+ // tests for defaults without id
+ tcs = append(tcs,
+ newTestCase{"", [2]bool{false, false}, &dbus.Config{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{"", [2]bool{false, true}, &dbus.Config{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{"", [2]bool{true, false}, &dbus.Config{
+ Talk: []string{"org.freedesktop.DBus", "org.freedesktop.Notifications"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Filter: true,
+ }},
+ newTestCase{"", [2]bool{true, true}, &dbus.Config{
+ Talk: []string{"org.freedesktop.DBus", "org.freedesktop.Notifications"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Filter: true,
+ }},
+ )
+ for _, id := range ids {
+ tcs = append(tcs,
+ newTestCase{id, [2]bool{false, false}, &dbus.Config{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{id, [2]bool{false, true}, &dbus.Config{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{id, [2]bool{true, false}, &dbus.Config{
+ Talk: []string{"org.freedesktop.DBus", "org.freedesktop.Notifications"},
+ Own: []string{id + ".*"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Filter: true,
+ }},
+ newTestCase{id, [2]bool{true, true}, &dbus.Config{
+ Talk: []string{"org.freedesktop.DBus", "org.freedesktop.Notifications"},
+ Own: []string{id + ".*", "org.mpris.MediaPlayer2." + id + ".*"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Filter: true,
+ }},
+ )
+ }
+
+ for _, tc := range tcs {
+ name := new(strings.Builder)
+ name.WriteString("create new configuration struct")
+
+ if tc.args[0] {
+ name.WriteString(" with builtin defaults")
+ if tc.args[1] {
+ name.WriteString(" (mpris)")
+ }
+ }
+
+ if tc.id != "" {
+ name.WriteString(" for application ID ")
+ name.WriteString(tc.id)
+ }
+
+ t.Run(name.String(), func(t *testing.T) {
+ if gotC := dbus.NewConfig(tc.id, tc.args[0], tc.args[1]); !reflect.DeepEqual(gotC, tc.want) {
+ t.Errorf("NewConfig(%q, %t, %t) = %v, want %v",
+ tc.id, tc.args[0], tc.args[1],
+ gotC, tc.want)
+ }
+ })
+ }
+}
diff --git a/system/dbus/dbus.go b/system/dbus/dbus.go
new file mode 100644
index 00000000..c5b4e812
--- /dev/null
+++ b/system/dbus/dbus.go
@@ -0,0 +1,40 @@
+// Package dbus wraps xdg-dbus-proxy and implements configuration and sandboxing of the underlying helper process.
+package dbus
+
+import (
+ "fmt"
+ "os"
+ "sync"
+)
+
+const (
+ SessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
+ SystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
+)
+
+var (
+ addresses [2]string
+ addressOnce sync.Once
+)
+
+func Address() (session, system string) {
+ addressOnce.Do(func() {
+ // resolve upstream session bus address
+ if addr, ok := os.LookupEnv(SessionBusAddress); !ok {
+ // fall back to default format
+ addresses[0] = fmt.Sprintf("unix:path=/run/user/%d/bus", os.Getuid())
+ } else {
+ addresses[0] = addr
+ }
+
+ // resolve upstream system bus address
+ if addr, ok := os.LookupEnv(SystemBusAddress); !ok {
+ // fall back to default hardcoded value
+ addresses[1] = "unix:path=/run/dbus/system_bus_socket"
+ } else {
+ addresses[1] = addr
+ }
+ })
+
+ return addresses[0], addresses[1]
+}
diff --git a/system/dbus/dbus_test.go b/system/dbus/dbus_test.go
new file mode 100644
index 00000000..7b1e1858
--- /dev/null
+++ b/system/dbus/dbus_test.go
@@ -0,0 +1,213 @@
+package dbus_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "git.gensokyo.uk/security/hakurei"
+ "git.gensokyo.uk/security/hakurei/helper"
+ "git.gensokyo.uk/security/hakurei/internal"
+ "git.gensokyo.uk/security/hakurei/internal/hlog"
+ "git.gensokyo.uk/security/hakurei/system/dbus"
+)
+
+func TestFinalise(t *testing.T) {
+ if _, err := dbus.Finalise(dbus.ProxyPair{}, dbus.ProxyPair{}, nil, nil); !errors.Is(err, syscall.EBADE) {
+ t.Errorf("Finalise: error = %v, want %v",
+ err, syscall.EBADE)
+ }
+
+ for id, tc := range testCasePairs() {
+ t.Run("create final for "+id, func(t *testing.T) {
+ var wt io.WriterTo
+ if v, err := dbus.Finalise(tc[0].bus, tc[1].bus, tc[0].c, tc[1].c); (errors.Is(err, syscall.EINVAL)) != tc[0].wantErr {
+ t.Errorf("Finalise: error = %v, wantErr %v",
+ err, tc[0].wantErr)
+ return
+ } else {
+ wt = v
+ }
+
+ // rest of the tests happen for sealed instances
+ if tc[0].wantErr {
+ return
+ }
+
+ // build null-terminated string from wanted args
+ want := new(strings.Builder)
+ args := append(tc[0].want, tc[1].want...)
+ for _, arg := range args {
+ want.WriteString(arg)
+ want.WriteByte(0)
+ }
+
+ got := new(strings.Builder)
+ if _, err := wt.WriteTo(got); err != nil {
+ t.Errorf("WriteTo: error = %v", err)
+ }
+
+ if want.String() != got.String() {
+ t.Errorf("Seal: %q, want %q",
+ got.String(), want.String())
+ }
+ })
+ }
+}
+
+func TestProxyStartWaitCloseString(t *testing.T) {
+ oldWaitDelay := helper.WaitDelay
+ helper.WaitDelay = 16 * time.Second
+ t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
+
+ t.Run("sandbox", func(t *testing.T) {
+ proxyName := dbus.ProxyName
+ dbus.ProxyName = os.Args[0]
+ t.Cleanup(func() { dbus.ProxyName = proxyName })
+ testProxyFinaliseStartWaitCloseString(t, true)
+ })
+ t.Run("direct", func(t *testing.T) { testProxyFinaliseStartWaitCloseString(t, false) })
+}
+
+func testProxyFinaliseStartWaitCloseString(t *testing.T, useSandbox bool) {
+ var p *dbus.Proxy
+
+ t.Run("string for nil proxy", func(t *testing.T) {
+ want := "(invalid dbus proxy)"
+ if got := p.String(); got != want {
+ t.Errorf("String: %q, want %q",
+ got, want)
+ }
+ })
+
+ t.Run("invalid start", func(t *testing.T) {
+ if !useSandbox {
+ p = dbus.NewDirect(t.Context(), nil, nil)
+ } else {
+ p = dbus.New(t.Context(), nil, nil)
+ }
+
+ if err := p.Start(); !errors.Is(err, syscall.ENOTRECOVERABLE) {
+ t.Errorf("Start: error = %q, wantErr %q",
+ err, syscall.ENOTRECOVERABLE)
+ return
+ }
+ })
+
+ for id, tc := range testCasePairs() {
+ // this test does not test errors
+ if tc[0].wantErr {
+ continue
+ }
+
+ t.Run("proxy for "+id, func(t *testing.T) {
+ var final *dbus.Final
+ t.Run("finalise", func(t *testing.T) {
+ if v, err := dbus.Finalise(tc[0].bus, tc[1].bus, tc[0].c, tc[1].c); err != nil {
+ t.Errorf("Finalise: error = %v, wantErr %v",
+ err, tc[0].wantErr)
+ return
+ } else {
+ final = v
+ }
+ })
+
+ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
+ defer cancel()
+ if !useSandbox {
+ p = dbus.NewDirect(ctx, final, nil)
+ } else {
+ p = dbus.New(ctx, final, nil)
+ }
+
+ p.CommandContext = func(ctx context.Context) (cmd *exec.Cmd) {
+ return exec.CommandContext(ctx, os.Args[0], "-test.v",
+ "-test.run=TestHelperInit", "--", "init")
+ }
+ p.CmdF = func(v any) {
+ if useSandbox {
+ container := v.(*hakurei.Container)
+ if container.Args[0] != dbus.ProxyName {
+ panic(fmt.Sprintf("unexpected argv0 %q", os.Args[0]))
+ }
+ container.Args = append([]string{os.Args[0], "-test.run=TestHelperStub", "--"}, container.Args[1:]...)
+ } else {
+ cmd := v.(*exec.Cmd)
+ if cmd.Args[0] != dbus.ProxyName {
+ panic(fmt.Sprintf("unexpected argv0 %q", os.Args[0]))
+ }
+ cmd.Err = nil
+ cmd.Path = os.Args[0]
+ cmd.Args = append([]string{os.Args[0], "-test.run=TestHelperStub", "--"}, cmd.Args[1:]...)
+ }
+ }
+ p.FilterF = func(v []byte) []byte { return bytes.SplitN(v, []byte("TestHelperInit\n"), 2)[1] }
+ output := new(strings.Builder)
+
+ t.Run("invalid wait", func(t *testing.T) {
+ wantErr := "dbus: not started"
+ if err := p.Wait(); err == nil || err.Error() != wantErr {
+ t.Errorf("Wait: error = %v, wantErr %v",
+ err, wantErr)
+ }
+ })
+
+ t.Run("string", func(t *testing.T) {
+ want := "(unused dbus proxy)"
+ if got := p.String(); got != want {
+ t.Errorf("String: %q, want %q",
+ got, want)
+ return
+ }
+ })
+
+ t.Run("start", func(t *testing.T) {
+ if err := p.Start(); err != nil {
+ t.Fatalf("Start: error = %v",
+ err)
+ }
+
+ t.Run("string", func(t *testing.T) {
+ wantSubstr := fmt.Sprintf("%s -test.run=TestHelperStub -- --args=3 --fd=4", os.Args[0])
+ if useSandbox {
+ wantSubstr = fmt.Sprintf(`argv: ["%s" "-test.run=TestHelperStub" "--" "--args=3" "--fd=4"], filter: true, rules: 0, flags: 0x1, presets: 0xf`, os.Args[0])
+ }
+ if got := p.String(); !strings.Contains(got, wantSubstr) {
+ t.Errorf("String: %q, want %q",
+ got, wantSubstr)
+ return
+ }
+ })
+
+ t.Run("wait", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ if err := p.Wait(); err != nil {
+ t.Errorf("Wait: error = %v\noutput: %s",
+ err, output.String())
+ }
+ close(done)
+ }()
+ p.Close()
+ <-done
+ })
+ })
+ })
+ }
+}
+
+func TestHelperInit(t *testing.T) {
+ if len(os.Args) != 5 || os.Args[4] != "init" {
+ return
+ }
+ hakurei.SetOutput(hlog.Output{})
+ hakurei.Init(hlog.Prepare, internal.InstallOutput)
+}
diff --git a/system/dbus/export_test.go b/system/dbus/export_test.go
new file mode 100644
index 00000000..574a4a73
--- /dev/null
+++ b/system/dbus/export_test.go
@@ -0,0 +1,13 @@
+package dbus
+
+import (
+ "context"
+ "io"
+)
+
+// NewDirect returns a new instance of [Proxy] with its sandbox disabled.
+func NewDirect(ctx context.Context, final *Final, output io.Writer) *Proxy {
+ p := New(ctx, final, output)
+ p.useSandbox = false
+ return p
+}
diff --git a/system/dbus/proc.go b/system/dbus/proc.go
new file mode 100644
index 00000000..e8cccd5f
--- /dev/null
+++ b/system/dbus/proc.go
@@ -0,0 +1,189 @@
+package dbus
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "syscall"
+
+ "git.gensokyo.uk/security/hakurei"
+ "git.gensokyo.uk/security/hakurei/helper"
+ "git.gensokyo.uk/security/hakurei/ldd"
+ "git.gensokyo.uk/security/hakurei/seccomp"
+)
+
+// Start starts and configures a D-Bus proxy process.
+func (p *Proxy) Start() error {
+ if p.final == nil || p.final.WriterTo == nil {
+ return syscall.ENOTRECOVERABLE
+ }
+
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.pmu.Lock()
+ defer p.pmu.Unlock()
+
+ if p.cancel != nil || p.cause != nil {
+ return errors.New("dbus: already started")
+ }
+
+ ctx, cancel := context.WithCancelCause(p.ctx)
+
+ if !p.useSandbox {
+ p.helper = helper.NewDirect(ctx, p.name, p.final, true, argF, func(cmd *exec.Cmd) {
+ if p.CmdF != nil {
+ p.CmdF(cmd)
+ }
+ if p.output != nil {
+ cmd.Stdout, cmd.Stderr = p.output, p.output
+ }
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Env = make([]string, 0)
+ }, nil)
+ } else {
+ toolPath := p.name
+ if filepath.Base(p.name) == p.name {
+ if s, err := exec.LookPath(p.name); err != nil {
+ return err
+ } else {
+ toolPath = s
+ }
+ }
+
+ var libPaths []string
+ if entries, err := ldd.ExecFilter(ctx, p.CommandContext, p.FilterF, toolPath); err != nil {
+ return err
+ } else {
+ libPaths = ldd.Path(entries)
+ }
+
+ p.helper = helper.New(
+ ctx, toolPath,
+ p.final, true,
+ argF, func(container *hakurei.Container) {
+ container.SeccompFlags |= seccomp.AllowMultiarch
+ container.SeccompPresets |= seccomp.PresetStrict
+ container.Hostname = "hakurei-dbus"
+ container.CommandContext = p.CommandContext
+ if p.output != nil {
+ container.Stdout, container.Stderr = p.output, p.output
+ }
+
+ if p.CmdF != nil {
+ p.CmdF(container)
+ }
+
+ // these lib paths are unpredictable, so mount them first so they cannot cover anything
+ for _, name := range libPaths {
+ container.Bind(name, name, 0)
+ }
+
+ // upstream bus directories
+ upstreamPaths := make([]string, 0, 2)
+ for _, addr := range [][]AddrEntry{p.final.SessionUpstream, p.final.SystemUpstream} {
+ for _, ent := range addr {
+ if ent.Method != "unix" {
+ continue
+ }
+ for _, pair := range ent.Values {
+ if pair[0] != "path" || !path.IsAbs(pair[1]) {
+ continue
+ }
+ upstreamPaths = append(upstreamPaths, path.Dir(pair[1]))
+ }
+ }
+ }
+ slices.Sort(upstreamPaths)
+ upstreamPaths = slices.Compact(upstreamPaths)
+ for _, name := range upstreamPaths {
+ container.Bind(name, name, 0)
+ }
+
+ // parent directories of bind paths
+ sockDirPaths := make([]string, 0, 2)
+ if d := path.Dir(p.final.Session[1]); path.IsAbs(d) {
+ sockDirPaths = append(sockDirPaths, d)
+ }
+ if d := path.Dir(p.final.System[1]); path.IsAbs(d) {
+ sockDirPaths = append(sockDirPaths, d)
+ }
+ slices.Sort(sockDirPaths)
+ sockDirPaths = slices.Compact(sockDirPaths)
+ for _, name := range sockDirPaths {
+ container.Bind(name, name, hakurei.BindWritable)
+ }
+
+ // xdg-dbus-proxy bin path
+ binPath := path.Dir(toolPath)
+ container.Bind(binPath, binPath, 0)
+ }, nil)
+ }
+
+ if err := p.helper.Start(); err != nil {
+ cancel(err)
+ p.helper = nil
+ return err
+ }
+
+ p.cancel, p.cause = cancel, func() error { return context.Cause(ctx) }
+ return nil
+}
+
+var proxyClosed = errors.New("proxy closed")
+
+// Wait blocks until xdg-dbus-proxy exits and releases resources.
+func (p *Proxy) Wait() error {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+
+ p.pmu.RLock()
+ if p.helper == nil || p.cancel == nil || p.cause == nil {
+ p.pmu.RUnlock()
+ return errors.New("dbus: not started")
+ }
+
+ errs := make([]error, 3)
+
+ errs[0] = p.helper.Wait()
+ if errors.Is(errs[0], context.Canceled) &&
+ errors.Is(p.cause(), proxyClosed) {
+ errs[0] = nil
+ }
+ p.pmu.RUnlock()
+
+ // ensure socket removal so ephemeral directory is empty at revert
+ if err := os.Remove(p.final.Session[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
+ errs[1] = err
+ }
+ if p.final.System[1] != "" {
+ if err := os.Remove(p.final.System[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
+ errs[2] = err
+ }
+ }
+
+ return errors.Join(errs...)
+}
+
+// Close cancels the context passed to the helper instance attached to xdg-dbus-proxy.
+func (p *Proxy) Close() {
+ p.pmu.Lock()
+ defer p.pmu.Unlock()
+
+ if p.cancel == nil {
+ panic("dbus: not started")
+ }
+ p.cancel(proxyClosed)
+}
+
+func argF(argsFd, statFd int) []string {
+ if statFd == -1 {
+ return []string{"--args=" + strconv.Itoa(argsFd)}
+ } else {
+ return []string{"--args=" + strconv.Itoa(argsFd), "--fd=" + strconv.Itoa(statFd)}
+ }
+}
diff --git a/system/dbus/proxy.go b/system/dbus/proxy.go
new file mode 100644
index 00000000..cc0af1ea
--- /dev/null
+++ b/system/dbus/proxy.go
@@ -0,0 +1,117 @@
+package dbus
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+ "sync"
+ "syscall"
+
+ "git.gensokyo.uk/security/hakurei/helper"
+)
+
+// ProxyName is the file name or path to the proxy program.
+// Overriding ProxyName will only affect Proxy instance created after the change.
+var ProxyName = "xdg-dbus-proxy"
+
+type BadInterfaceError struct {
+ Interface string
+ Segment string
+}
+
+func (e *BadInterfaceError) Error() string {
+ return fmt.Sprintf("bad interface string %q in %s bus configuration", e.Interface, e.Segment)
+}
+
+// Proxy holds the state of a xdg-dbus-proxy process, and should never be copied.
+type Proxy struct {
+ helper helper.Helper
+ ctx context.Context
+
+ cancel context.CancelCauseFunc
+ cause func() error
+
+ final *Final
+ output io.Writer
+ useSandbox bool
+
+ name string
+ CmdF func(any)
+
+ CommandContext func(ctx context.Context) (cmd *exec.Cmd)
+ FilterF func([]byte) []byte
+
+ mu, pmu sync.RWMutex
+}
+
+func (p *Proxy) String() string {
+ if p == nil {
+ return "(invalid dbus proxy)"
+ }
+
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+
+ if p.helper != nil {
+ return p.helper.String()
+ }
+
+ return "(unused dbus proxy)"
+}
+
+// Final describes the outcome of a proxy configuration.
+type Final struct {
+ Session, System ProxyPair
+ // parsed upstream address
+ SessionUpstream, SystemUpstream []AddrEntry
+ io.WriterTo
+}
+
+// Finalise creates a checked argument writer for [Proxy].
+func Finalise(sessionBus, systemBus ProxyPair, session, system *Config) (final *Final, err error) {
+ if session == nil && system == nil {
+ return nil, syscall.EBADE
+ }
+
+ var args []string
+ if session != nil {
+ if err = session.checkInterfaces("session"); err != nil {
+ return
+ }
+ args = append(args, session.Args(sessionBus)...)
+ }
+ if system != nil {
+ if err = system.checkInterfaces("system"); err != nil {
+ return
+ }
+ args = append(args, system.Args(systemBus)...)
+ }
+
+ final = &Final{Session: sessionBus, System: systemBus}
+
+ final.WriterTo, err = helper.NewCheckedArgs(args)
+ if err != nil {
+ return
+ }
+
+ if session != nil {
+ final.SessionUpstream, err = Parse([]byte(final.Session[0]))
+ if err != nil {
+ return
+ }
+ }
+ if system != nil {
+ final.SystemUpstream, err = Parse([]byte(final.System[0]))
+ if err != nil {
+ return
+ }
+ }
+
+ return
+}
+
+// New returns a new instance of [Proxy].
+func New(ctx context.Context, final *Final, output io.Writer) *Proxy {
+ return &Proxy{name: ProxyName, ctx: ctx, final: final, output: output, useSandbox: true}
+}
diff --git a/system/dbus/samples_test.go b/system/dbus/samples_test.go
new file mode 100644
index 00000000..a5c6f273
--- /dev/null
+++ b/system/dbus/samples_test.go
@@ -0,0 +1,228 @@
+package dbus_test
+
+import (
+ "sync"
+
+ "git.gensokyo.uk/security/hakurei/system/dbus"
+)
+
+const (
+ sampleHostPath = "/tmp/bus"
+ sampleHostAddr = "unix:path=" + sampleHostPath
+ sampleBindPath = "/tmp/proxied_bus"
+)
+
+var samples = []dbusTestCase{
+ {
+ "org.chromium.Chromium", &dbus.Config{
+ See: nil,
+ Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
+ "org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
+ Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
+ "org.mpris.MediaPlayer2.chromium.*"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Log: false,
+ Filter: true,
+ }, false, false,
+ [2]string{sampleHostAddr, sampleBindPath},
+ []string{
+ sampleHostAddr,
+ sampleBindPath,
+ "--filter",
+ "--talk=org.freedesktop.Notifications",
+ "--talk=org.freedesktop.FileManager1",
+ "--talk=org.freedesktop.ScreenSaver",
+ "--talk=org.freedesktop.secrets",
+ "--talk=org.kde.kwalletd5",
+ "--talk=org.kde.kwalletd6",
+ "--talk=org.gnome.SessionManager",
+ "--own=org.chromium.Chromium.*",
+ "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*",
+ "--own=org.mpris.MediaPlayer2.chromium.*",
+ "--call=org.freedesktop.portal.*=*",
+ "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*",
+ },
+ },
+ {
+ "org.chromium.Chromium+", &dbus.Config{
+ See: nil,
+ Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
+ Own: nil,
+ Call: nil,
+ Broadcast: nil,
+ Log: false,
+ Filter: true,
+ }, false, false,
+ [2]string{sampleHostAddr, sampleBindPath},
+ []string{
+ sampleHostAddr,
+ sampleBindPath,
+ "--filter",
+ "--talk=org.bluez",
+ "--talk=org.freedesktop.Avahi",
+ "--talk=org.freedesktop.UPower",
+ },
+ },
+
+ {
+ "dev.vencord.Vesktop", &dbus.Config{
+ See: nil,
+ Talk: []string{"org.freedesktop.Notifications", "org.kde.StatusNotifierWatcher"},
+ Own: []string{"dev.vencord.Vesktop.*", "org.mpris.MediaPlayer2.dev.vencord.Vesktop.*"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Log: false,
+ Filter: true,
+ }, false, false,
+ [2]string{sampleHostAddr, sampleBindPath},
+ []string{
+ sampleHostAddr,
+ sampleBindPath,
+ "--filter",
+ "--talk=org.freedesktop.Notifications",
+ "--talk=org.kde.StatusNotifierWatcher",
+ "--own=dev.vencord.Vesktop.*",
+ "--own=org.mpris.MediaPlayer2.dev.vencord.Vesktop.*",
+ "--call=org.freedesktop.portal.*=*",
+ "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*"},
+ },
+
+ {
+ "uk.gensokyo.CrashTestDummy", &dbus.Config{
+ See: []string{"uk.gensokyo.CrashTestDummy1"},
+ Talk: []string{"org.freedesktop.Notifications"},
+ Own: []string{"uk.gensokyo.CrashTestDummy.*", "org.mpris.MediaPlayer2.uk.gensokyo.CrashTestDummy.*"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Log: true,
+ Filter: true,
+ }, false, false,
+ [2]string{sampleHostAddr, sampleBindPath},
+ []string{
+ sampleHostAddr,
+ sampleBindPath,
+ "--filter",
+ "--see=uk.gensokyo.CrashTestDummy1",
+ "--talk=org.freedesktop.Notifications",
+ "--own=uk.gensokyo.CrashTestDummy.*",
+ "--own=org.mpris.MediaPlayer2.uk.gensokyo.CrashTestDummy.*",
+ "--call=org.freedesktop.portal.*=*",
+ "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*",
+ "--log"},
+ },
+ {
+ "uk.gensokyo.CrashTestDummy1", &dbus.Config{
+ See: []string{"uk.gensokyo.CrashTestDummy"},
+ Talk: []string{"org.freedesktop.Notifications"},
+ Own: []string{"uk.gensokyo.CrashTestDummy1.*", "org.mpris.MediaPlayer2.uk.gensokyo.CrashTestDummy1.*"},
+ Call: map[string]string{"org.freedesktop.portal.*": "*"},
+ Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
+ Log: true,
+ Filter: true,
+ }, false, true,
+ [2]string{sampleHostAddr, sampleBindPath},
+ []string{
+ sampleHostAddr,
+ sampleBindPath,
+ "--filter",
+ "--see=uk.gensokyo.CrashTestDummy",
+ "--talk=org.freedesktop.Notifications",
+ "--own=uk.gensokyo.CrashTestDummy1.*",
+ "--own=org.mpris.MediaPlayer2.uk.gensokyo.CrashTestDummy1.*",
+ "--call=org.freedesktop.portal.*=*",
+ "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*",
+ "--log"},
+ },
+}
+
+type dbusTestCase struct {
+ id string
+ c *dbus.Config
+ wantErr bool
+ wantErrF bool
+ bus [2]string
+ want []string
+}
+
+var (
+ testCasesV []dbusTestCase
+ testCasePairsV map[string][2]dbusTestCase
+
+ testCaseOnce sync.Once
+)
+
+func makeTestCases() []dbusTestCase {
+ testCaseOnce.Do(testCaseGenerate)
+ return testCasesV
+}
+
+func testCasePairs() map[string][2]dbusTestCase {
+ testCaseOnce.Do(testCaseGenerate)
+ return testCasePairsV
+}
+
+func injectNulls(t *[]string) {
+ f := make([]string, len(*t))
+ for i := range f {
+ f[i] = "\x00" + (*t)[i] + "\x00"
+ }
+ *t = f
+}
+
+func testCaseGenerate() {
+ // create null-injected test cases
+ testCasesV = make([]dbusTestCase, len(samples)*2)
+ for i := range samples {
+ testCasesV[i] = samples[i]
+ testCasesV[len(samples)+i] = samples[i]
+ testCasesV[len(samples)+i].c = new(dbus.Config)
+ *testCasesV[len(samples)+i].c = *samples[i].c
+
+ // inject nulls
+ fi := &testCasesV[len(samples)+i]
+ fi.wantErr = true
+
+ injectNulls(&fi.c.See)
+ injectNulls(&fi.c.Talk)
+ injectNulls(&fi.c.Own)
+ }
+
+ // enumerate test case pairs
+ var pc int
+ for _, tc := range samples {
+ if tc.id != "" {
+ pc++
+ }
+ }
+ testCasePairsV = make(map[string][2]dbusTestCase, pc)
+ for i, tc := range testCasesV {
+ if tc.id == "" {
+ continue
+ }
+
+ // skip already enumerated system bus test
+ if tc.id[len(tc.id)-1] == '+' {
+ continue
+ }
+
+ ftp := [2]dbusTestCase{tc}
+
+ // system proxy tests always place directly after its user counterpart with id ending in +
+ if i+1 < len(testCasesV) && testCasesV[i+1].id[len(testCasesV[i+1].id)-1] == '+' {
+ // attach system bus config
+ ftp[1] = testCasesV[i+1]
+
+ // check for misplaced/mismatching tests
+ if ftp[0].wantErr != ftp[1].wantErr || ftp[0].id+"+" != ftp[1].id {
+ panic("mismatching session/system pairing")
+ }
+ }
+
+ k := tc.id
+ if tc.wantErr {
+ k = "malformed_" + k
+ }
+ testCasePairsV[k] = ftp
+ }
+}
diff --git a/system/dbus/stub_test.go b/system/dbus/stub_test.go
new file mode 100644
index 00000000..b05efd61
--- /dev/null
+++ b/system/dbus/stub_test.go
@@ -0,0 +1,9 @@
+package dbus_test
+
+import (
+ "testing"
+
+ "git.gensokyo.uk/security/hakurei/helper"
+)
+
+func TestHelperStub(t *testing.T) { helper.InternalHelperStub() }
diff --git a/system/dbus/testdata/dev.vencord.Vesktop.json b/system/dbus/testdata/dev.vencord.Vesktop.json
new file mode 100644
index 00000000..05259774
--- /dev/null
+++ b/system/dbus/testdata/dev.vencord.Vesktop.json
@@ -0,0 +1,18 @@
+{
+ "talk":[
+ "org.freedesktop.Notifications",
+ "org.kde.StatusNotifierWatcher"
+ ],
+ "own":[
+ "dev.vencord.Vesktop.*",
+ "org.mpris.MediaPlayer2.dev.vencord.Vesktop.*"
+ ],
+ "call":{
+ "org.freedesktop.portal.*":"*"
+ },
+ "broadcast":{
+ "org.freedesktop.portal.*":"@/org/freedesktop/portal/*"
+ },
+
+ "filter":true
+}
diff --git a/system/dbus/testdata/org.chromium.Chromium+.json b/system/dbus/testdata/org.chromium.Chromium+.json
new file mode 100644
index 00000000..0a27d719
--- /dev/null
+++ b/system/dbus/testdata/org.chromium.Chromium+.json
@@ -0,0 +1,9 @@
+{
+ "talk":[
+ "org.bluez",
+ "org.freedesktop.Avahi",
+ "org.freedesktop.UPower"
+ ],
+
+ "filter":true
+} \ No newline at end of file
diff --git a/system/dbus/testdata/org.chromium.Chromium.json b/system/dbus/testdata/org.chromium.Chromium.json
new file mode 100644
index 00000000..be027f39
--- /dev/null
+++ b/system/dbus/testdata/org.chromium.Chromium.json
@@ -0,0 +1,24 @@
+{
+ "talk":[
+ "org.freedesktop.Notifications",
+ "org.freedesktop.FileManager1",
+ "org.freedesktop.ScreenSaver",
+ "org.freedesktop.secrets",
+ "org.kde.kwalletd5",
+ "org.kde.kwalletd6",
+ "org.gnome.SessionManager"
+ ],
+ "own":[
+ "org.chromium.Chromium.*",
+ "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
+ "org.mpris.MediaPlayer2.chromium.*"
+ ],
+ "call":{
+ "org.freedesktop.portal.*":"*"
+ },
+ "broadcast":{
+ "org.freedesktop.portal.*":"@/org/freedesktop/portal/*"
+ },
+
+ "filter":true
+}
diff --git a/system/dbus/testdata/uk.gensokyo.CrashTestDummy.json b/system/dbus/testdata/uk.gensokyo.CrashTestDummy.json
new file mode 100644
index 00000000..43272f67
--- /dev/null
+++ b/system/dbus/testdata/uk.gensokyo.CrashTestDummy.json
@@ -0,0 +1,21 @@
+{
+ "see": [
+ "uk.gensokyo.CrashTestDummy1"
+ ],
+ "talk":[
+ "org.freedesktop.Notifications"
+ ],
+ "own":[
+ "uk.gensokyo.CrashTestDummy.*",
+ "org.mpris.MediaPlayer2.uk.gensokyo.CrashTestDummy.*"
+ ],
+ "call":{
+ "org.freedesktop.portal.*":"*"
+ },
+ "broadcast":{
+ "org.freedesktop.portal.*":"@/org/freedesktop/portal/*"
+ },
+
+ "log": true,
+ "filter":true
+}
diff --git a/system/wayland.go b/system/wayland.go
index cb547406..a485b3f2 100644
--- a/system/wayland.go
+++ b/system/wayland.go
@@ -5,8 +5,8 @@ import (
"fmt"
"os"
- "git.gensokyo.uk/security/hakurei/acl"
- "git.gensokyo.uk/security/hakurei/sandbox/wl"
+ "git.gensokyo.uk/security/hakurei/system/acl"
+ "git.gensokyo.uk/security/hakurei/system/wayland"
)
// Wayland sets up a wayland socket with a security context attached.
@@ -14,7 +14,7 @@ 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{}})
+ sys.ops = append(sys.ops, &Wayland{syncFd, dst, src, appID, instanceID, wayland.Conn{}})
return sys
}
@@ -24,7 +24,7 @@ type Wayland struct {
dst, src string
appID, instanceID string
- conn wl.Conn
+ conn wayland.Conn
}
func (w *Wayland) Type() Enablement { return Process }
diff --git a/system/wayland/conn.go b/system/wayland/conn.go
new file mode 100644
index 00000000..a930fc01
--- /dev/null
+++ b/system/wayland/conn.go
@@ -0,0 +1,121 @@
+// Package wayland implements Wayland security_context_v1 protocol.
+package wayland
+
+import (
+ "errors"
+ "net"
+ "os"
+ "runtime"
+ "sync"
+ "syscall"
+)
+
+type Conn struct {
+ conn *net.UnixConn
+
+ done chan struct{}
+ doneOnce sync.Once
+
+ mu sync.Mutex
+}
+
+// Attach connects Conn to a wayland socket.
+func (c *Conn) Attach(p string) (err error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.conn != nil {
+ return errors.New("attached")
+ }
+
+ c.conn, err = net.DialUnix("unix", nil, &net.UnixAddr{Name: p, Net: "unix"})
+ return
+}
+
+// Close releases resources and closes the connection to the wayland compositor.
+func (c *Conn) Close() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.done == nil {
+ return errors.New("no socket bound")
+ }
+
+ c.doneOnce.Do(func() {
+ c.done <- struct{}{}
+ <-c.done
+ })
+
+ // closed by wayland
+ runtime.SetFinalizer(c.conn, nil)
+ return nil
+}
+
+func (c *Conn) Bind(p, appID, instanceID string) (*os.File, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.conn == nil {
+ return nil, errors.New("not attached")
+ }
+ if c.done != nil {
+ return nil, errors.New("bound")
+ }
+
+ if rc, err := c.conn.SyscallConn(); err != nil {
+ // unreachable
+ return nil, err
+ } else {
+ c.done = make(chan struct{})
+ return bindRawConn(c.done, rc, p, appID, instanceID)
+ }
+}
+
+func bindRawConn(done chan struct{}, rc syscall.RawConn, p, appID, instanceID string) (*os.File, error) {
+ var syncPipe [2]*os.File
+
+ if r, w, err := os.Pipe(); err != nil {
+ return nil, err
+ } else {
+ syncPipe[0] = r
+ syncPipe[1] = w
+ }
+
+ setupDone := make(chan error, 1) // does not block with c.done
+
+ go func() {
+ if err := rc.Control(func(fd uintptr) {
+ // prevent runtime from closing the read end of sync fd
+ runtime.SetFinalizer(syncPipe[0], nil)
+
+ // allow the Bind method to return after setup
+ setupDone <- bind(fd, p, appID, instanceID, syncPipe[0].Fd())
+ close(setupDone)
+
+ // keep socket alive until done is requested
+ <-done
+ runtime.KeepAlive(syncPipe[1])
+ }); err != nil {
+ setupDone <- err
+ }
+
+ // notify Close that rc.Control has returned
+ close(done)
+ }()
+
+ // return write end of the pipe
+ return syncPipe[1], <-setupDone
+}
+
+func bind(fd uintptr, p, appID, instanceID string, syncFd uintptr) error {
+ // ensure p is available
+ if f, err := os.Create(p); err != nil {
+ return err
+ } else if err = f.Close(); err != nil {
+ return err
+ } else if err = os.Remove(p); err != nil {
+ return err
+ }
+
+ return bindWaylandFd(p, fd, appID, instanceID, syncFd)
+}
diff --git a/system/wayland/consts.go b/system/wayland/consts.go
new file mode 100644
index 00000000..672e6392
--- /dev/null
+++ b/system/wayland/consts.go
@@ -0,0 +1,15 @@
+package wayland
+
+const (
+ // WaylandDisplay contains the name of the server socket
+ // (https://gitlab.freedesktop.org/wayland/wayland/-/blob/1.23.1/src/wayland-client.c#L1147)
+ // which is concatenated with XDG_RUNTIME_DIR
+ // (https://gitlab.freedesktop.org/wayland/wayland/-/blob/1.23.1/src/wayland-client.c#L1171)
+ // or used as-is if absolute
+ // (https://gitlab.freedesktop.org/wayland/wayland/-/blob/1.23.1/src/wayland-client.c#L1176).
+ WaylandDisplay = "WAYLAND_DISPLAY"
+
+ // FallbackName is used as the wayland socket name if WAYLAND_DISPLAY is unset
+ // (https://gitlab.freedesktop.org/wayland/wayland/-/blob/1.23.1/src/wayland-client.c#L1149).
+ FallbackName = "wayland-0"
+)
diff --git a/system/wayland/security-context-v1-protocol.c b/system/wayland/security-context-v1-protocol.c
new file mode 100644
index 00000000..5b15b1b4
--- /dev/null
+++ b/system/wayland/security-context-v1-protocol.c
@@ -0,0 +1,74 @@
+/* Generated by wayland-scanner 1.23.1 */
+
+/*
+ * Copyright © 2021 Simon Ser
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+#include <stdbool.h>
+#include <stdlib.h>
+#include <stdint.h>
+#include "wayland-util.h"
+
+#ifndef __has_attribute
+# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
+#endif
+
+#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
+#define WL_PRIVATE __attribute__ ((visibility("hidden")))
+#else
+#define WL_PRIVATE
+#endif
+
+extern const struct wl_interface wp_security_context_v1_interface;
+
+static const struct wl_interface *security_context_v1_types[] = {
+ NULL,
+ &wp_security_context_v1_interface,
+ NULL,
+ NULL,
+};
+
+static const struct wl_message wp_security_context_manager_v1_requests[] = {
+ { "destroy", "", security_context_v1_types + 0 },
+ { "create_listener", "nhh", security_context_v1_types + 1 },
+};
+
+WL_PRIVATE const struct wl_interface wp_security_context_manager_v1_interface = {
+ "wp_security_context_manager_v1", 1,
+ 2, wp_security_context_manager_v1_requests,
+ 0, NULL,
+};
+
+static const struct wl_message wp_security_context_v1_requests[] = {
+ { "destroy", "", security_context_v1_types + 0 },
+ { "set_sandbox_engine", "s", security_context_v1_types + 0 },
+ { "set_app_id", "s", security_context_v1_types + 0 },
+ { "set_instance_id", "s", security_context_v1_types + 0 },
+ { "commit", "", security_context_v1_types + 0 },
+};
+
+WL_PRIVATE const struct wl_interface wp_security_context_v1_interface = {
+ "wp_security_context_v1", 1,
+ 5, wp_security_context_v1_requests,
+ 0, NULL,
+};
+
diff --git a/system/wayland/security-context-v1-protocol.h b/system/wayland/security-context-v1-protocol.h
new file mode 100644
index 00000000..f973619e
--- /dev/null
+++ b/system/wayland/security-context-v1-protocol.h
@@ -0,0 +1,392 @@
+/* Generated by wayland-scanner 1.23.1 */
+
+#ifndef SECURITY_CONTEXT_V1_CLIENT_PROTOCOL_H
+#define SECURITY_CONTEXT_V1_CLIENT_PROTOCOL_H
+
+#include <stdint.h>
+#include <stddef.h>
+#include "wayland-client.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @page page_security_context_v1 The security_context_v1 protocol
+ * @section page_ifaces_security_context_v1 Interfaces
+ * - @subpage page_iface_wp_security_context_manager_v1 - client security context manager
+ * - @subpage page_iface_wp_security_context_v1 - client security context
+ * @section page_copyright_security_context_v1 Copyright
+ * <pre>
+ *
+ * Copyright © 2021 Simon Ser
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ * </pre>
+ */
+struct wp_security_context_manager_v1;
+struct wp_security_context_v1;
+
+#ifndef WP_SECURITY_CONTEXT_MANAGER_V1_INTERFACE
+#define WP_SECURITY_CONTEXT_MANAGER_V1_INTERFACE
+/**
+ * @page page_iface_wp_security_context_manager_v1 wp_security_context_manager_v1
+ * @section page_iface_wp_security_context_manager_v1_desc Description
+ *
+ * This interface allows a client to register a new Wayland connection to
+ * the compositor and attach a security context to it.
+ *
+ * This is intended to be used by sandboxes. Sandbox engines attach a
+ * security context to all connections coming from inside the sandbox. The
+ * compositor can then restrict the features that the sandboxed connections
+ * can use.
+ *
+ * Compositors should forbid nesting multiple security contexts by not
+ * exposing wp_security_context_manager_v1 global to clients with a security
+ * context attached, or by sending the nested protocol error. Nested
+ * security contexts are dangerous because they can potentially allow
+ * privilege escalation of a sandboxed client.
+ *
+ * Warning! The protocol described in this file is currently in the testing
+ * phase. Backward compatible changes may be added together with the
+ * corresponding interface version bump. Backward incompatible changes can
+ * only be done by creating a new major version of the extension.
+ * @section page_iface_wp_security_context_manager_v1_api API
+ * See @ref iface_wp_security_context_manager_v1.
+ */
+/**
+ * @defgroup iface_wp_security_context_manager_v1 The wp_security_context_manager_v1 interface
+ *
+ * This interface allows a client to register a new Wayland connection to
+ * the compositor and attach a security context to it.
+ *
+ * This is intended to be used by sandboxes. Sandbox engines attach a
+ * security context to all connections coming from inside the sandbox. The
+ * compositor can then restrict the features that the sandboxed connections
+ * can use.
+ *
+ * Compositors should forbid nesting multiple security contexts by not
+ * exposing wp_security_context_manager_v1 global to clients with a security
+ * context attached, or by sending the nested protocol error. Nested
+ * security contexts are dangerous because they can potentially allow
+ * privilege escalation of a sandboxed client.
+ *
+ * Warning! The protocol described in this file is currently in the testing
+ * phase. Backward compatible changes may be added together with the
+ * corresponding interface version bump. Backward incompatible changes can
+ * only be done by creating a new major version of the extension.
+ */
+extern const struct wl_interface wp_security_context_manager_v1_interface;
+#endif
+#ifndef WP_SECURITY_CONTEXT_V1_INTERFACE
+#define WP_SECURITY_CONTEXT_V1_INTERFACE
+/**
+ * @page page_iface_wp_security_context_v1 wp_security_context_v1
+ * @section page_iface_wp_security_context_v1_desc Description
+ *
+ * The security context allows a client to register a new client and attach
+ * security context metadata to the connections.
+ *
+ * When both are set, the combination of the application ID and the sandbox
+ * engine must uniquely identify an application. The same application ID
+ * will be used across instances (e.g. if the application is restarted, or
+ * if the application is started multiple times).
+ *
+ * When both are set, the combination of the instance ID and the sandbox
+ * engine must uniquely identify a running instance of an application.
+ * @section page_iface_wp_security_context_v1_api API
+ * See @ref iface_wp_security_context_v1.
+ */
+/**
+ * @defgroup iface_wp_security_context_v1 The wp_security_context_v1 interface
+ *
+ * The security context allows a client to register a new client and attach
+ * security context metadata to the connections.
+ *
+ * When both are set, the combination of the application ID and the sandbox
+ * engine must uniquely identify an application. The same application ID
+ * will be used across instances (e.g. if the application is restarted, or
+ * if the application is started multiple times).
+ *
+ * When both are set, the combination of the instance ID and the sandbox
+ * engine must uniquely identify a running instance of an application.
+ */
+extern const struct wl_interface wp_security_context_v1_interface;
+#endif
+
+#ifndef WP_SECURITY_CONTEXT_MANAGER_V1_ERROR_ENUM
+#define WP_SECURITY_CONTEXT_MANAGER_V1_ERROR_ENUM
+enum wp_security_context_manager_v1_error {
+ /**
+ * listening socket FD is invalid
+ */
+ WP_SECURITY_CONTEXT_MANAGER_V1_ERROR_INVALID_LISTEN_FD = 1,
+ /**
+ * nested security contexts are forbidden
+ */
+ WP_SECURITY_CONTEXT_MANAGER_V1_ERROR_NESTED = 2,
+};
+#endif /* WP_SECURITY_CONTEXT_MANAGER_V1_ERROR_ENUM */
+
+#define WP_SECURITY_CONTEXT_MANAGER_V1_DESTROY 0
+#define WP_SECURITY_CONTEXT_MANAGER_V1_CREATE_LISTENER 1
+
+
+/**
+ * @ingroup iface_wp_security_context_manager_v1
+ */
+#define WP_SECURITY_CONTEXT_MANAGER_V1_DESTROY_SINCE_VERSION 1
+/**
+ * @ingroup iface_wp_security_context_manager_v1
+ */
+#define WP_SECURITY_CONTEXT_MANAGER_V1_CREATE_LISTENER_SINCE_VERSION 1
+
+/** @ingroup iface_wp_security_context_manager_v1 */
+static inline void
+wp_security_context_manager_v1_set_user_data(struct wp_security_context_manager_v1 *wp_security_context_manager_v1, void *user_data)
+{
+ wl_proxy_set_user_data((struct wl_proxy *) wp_security_context_manager_v1, user_data);
+}
+
+/** @ingroup iface_wp_security_context_manager_v1 */
+static inline void *
+wp_security_context_manager_v1_get_user_data(struct wp_security_context_manager_v1 *wp_security_context_manager_v1)
+{
+ return wl_proxy_get_user_data((struct wl_proxy *) wp_security_context_manager_v1);
+}
+
+static inline uint32_t
+wp_security_context_manager_v1_get_version(struct wp_security_context_manager_v1 *wp_security_context_manager_v1)
+{
+ return wl_proxy_get_version((struct wl_proxy *) wp_security_context_manager_v1);
+}
+
+/**
+ * @ingroup iface_wp_security_context_manager_v1
+ *
+ * Destroy the manager. This doesn't destroy objects created with the
+ * manager.
+ */
+static inline void
+wp_security_context_manager_v1_destroy(struct wp_security_context_manager_v1 *wp_security_context_manager_v1)
+{
+ wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_manager_v1,
+ WP_SECURITY_CONTEXT_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) wp_security_context_manager_v1), WL_MARSHAL_FLAG_DESTROY);
+}
+
+/**
+ * @ingroup iface_wp_security_context_manager_v1
+ *
+ * Creates a new security context with a socket listening FD.
+ *
+ * The compositor will accept new client connections on listen_fd.
+ * listen_fd must be ready to accept new connections when this request is
+ * sent by the client. In other words, the client must call bind(2) and
+ * listen(2) before sending the FD.
+ *
+ * close_fd is a FD that will signal hangup when the compositor should stop
+ * accepting new connections on listen_fd.
+ *
+ * The compositor must continue to accept connections on listen_fd when
+ * the Wayland client which created the security context disconnects.
+ *
+ * After sending this request, closing listen_fd and close_fd remains the
+ * only valid operation on them.
+ */
+static inline struct wp_security_context_v1 *
+wp_security_context_manager_v1_create_listener(struct wp_security_context_manager_v1 *wp_security_context_manager_v1, int32_t listen_fd, int32_t close_fd)
+{
+ struct wl_proxy *id;
+
+ id = wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_manager_v1,
+ WP_SECURITY_CONTEXT_MANAGER_V1_CREATE_LISTENER, &wp_security_context_v1_interface, wl_proxy_get_version((struct wl_proxy *) wp_security_context_manager_v1), 0, NULL, listen_fd, close_fd);
+
+ return (struct wp_security_context_v1 *) id;
+}
+
+#ifndef WP_SECURITY_CONTEXT_V1_ERROR_ENUM
+#define WP_SECURITY_CONTEXT_V1_ERROR_ENUM
+enum wp_security_context_v1_error {
+ /**
+ * security context has already been committed
+ */
+ WP_SECURITY_CONTEXT_V1_ERROR_ALREADY_USED = 1,
+ /**
+ * metadata has already been set
+ */
+ WP_SECURITY_CONTEXT_V1_ERROR_ALREADY_SET = 2,
+ /**
+ * metadata is invalid
+ */
+ WP_SECURITY_CONTEXT_V1_ERROR_INVALID_METADATA = 3,
+};
+#endif /* WP_SECURITY_CONTEXT_V1_ERROR_ENUM */
+
+#define WP_SECURITY_CONTEXT_V1_DESTROY 0
+#define WP_SECURITY_CONTEXT_V1_SET_SANDBOX_ENGINE 1
+#define WP_SECURITY_CONTEXT_V1_SET_APP_ID 2
+#define WP_SECURITY_CONTEXT_V1_SET_INSTANCE_ID 3
+#define WP_SECURITY_CONTEXT_V1_COMMIT 4
+
+
+/**
+ * @ingroup iface_wp_security_context_v1
+ */
+#define WP_SECURITY_CONTEXT_V1_DESTROY_SINCE_VERSION 1
+/**
+ * @ingroup iface_wp_security_context_v1
+ */
+#define WP_SECURITY_CONTEXT_V1_SET_SANDBOX_ENGINE_SINCE_VERSION 1
+/**
+ * @ingroup iface_wp_security_context_v1
+ */
+#define WP_SECURITY_CONTEXT_V1_SET_APP_ID_SINCE_VERSION 1
+/**
+ * @ingroup iface_wp_security_context_v1
+ */
+#define WP_SECURITY_CONTEXT_V1_SET_INSTANCE_ID_SINCE_VERSION 1
+/**
+ * @ingroup iface_wp_security_context_v1
+ */
+#define WP_SECURITY_CONTEXT_V1_COMMIT_SINCE_VERSION 1
+
+/** @ingroup iface_wp_security_context_v1 */
+static inline void
+wp_security_context_v1_set_user_data(struct wp_security_context_v1 *wp_security_context_v1, void *user_data)
+{
+ wl_proxy_set_user_data((struct wl_proxy *) wp_security_context_v1, user_data);
+}
+
+/** @ingroup iface_wp_security_context_v1 */
+static inline void *
+wp_security_context_v1_get_user_data(struct wp_security_context_v1 *wp_security_context_v1)
+{
+ return wl_proxy_get_user_data((struct wl_proxy *) wp_security_context_v1);
+}
+
+static inline uint32_t
+wp_security_context_v1_get_version(struct wp_security_context_v1 *wp_security_context_v1)
+{
+ return wl_proxy_get_version((struct wl_proxy *) wp_security_context_v1);
+}
+
+/**
+ * @ingroup iface_wp_security_context_v1
+ *
+ * Destroy the security context object.
+ */
+static inline void
+wp_security_context_v1_destroy(struct wp_security_context_v1 *wp_security_context_v1)
+{
+ wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_v1,
+ WP_SECURITY_CONTEXT_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) wp_security_context_v1), WL_MARSHAL_FLAG_DESTROY);
+}
+
+/**
+ * @ingroup iface_wp_security_context_v1
+ *
+ * Attach a unique sandbox engine name to the security context. The name
+ * should follow the reverse-DNS style (e.g. "org.flatpak").
+ *
+ * A list of well-known engines is maintained at:
+ * https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/main/staging/security-context/engines.md
+ *
+ * It is a protocol error to call this request twice. The already_set
+ * error is sent in this case.
+ */
+static inline void
+wp_security_context_v1_set_sandbox_engine(struct wp_security_context_v1 *wp_security_context_v1, const char *name)
+{
+ wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_v1,
+ WP_SECURITY_CONTEXT_V1_SET_SANDBOX_ENGINE, NULL, wl_proxy_get_version((struct wl_proxy *) wp_security_context_v1), 0, name);
+}
+
+/**
+ * @ingroup iface_wp_security_context_v1
+ *
+ * Attach an application ID to the security context.
+ *
+ * The application ID is an opaque, sandbox-specific identifier for an
+ * application. See the well-known engines document for more details:
+ * https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/main/staging/security-context/engines.md
+ *
+ * The compositor may use the application ID to group clients belonging to
+ * the same security context application.
+ *
+ * Whether this request is optional or not depends on the sandbox engine used.
+ *
+ * It is a protocol error to call this request twice. The already_set
+ * error is sent in this case.
+ */
+static inline void
+wp_security_context_v1_set_app_id(struct wp_security_context_v1 *wp_security_context_v1, const char *app_id)
+{
+ wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_v1,
+ WP_SECURITY_CONTEXT_V1_SET_APP_ID, NULL, wl_proxy_get_version((struct wl_proxy *) wp_security_context_v1), 0, app_id);
+}
+
+/**
+ * @ingroup iface_wp_security_context_v1
+ *
+ * Attach an instance ID to the security context.
+ *
+ * The instance ID is an opaque, sandbox-specific identifier for a running
+ * instance of an application. See the well-known engines document for
+ * more details:
+ * https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/main/staging/security-context/engines.md
+ *
+ * Whether this request is optional or not depends on the sandbox engine used.
+ *
+ * It is a protocol error to call this request twice. The already_set
+ * error is sent in this case.
+ */
+static inline void
+wp_security_context_v1_set_instance_id(struct wp_security_context_v1 *wp_security_context_v1, const char *instance_id)
+{
+ wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_v1,
+ WP_SECURITY_CONTEXT_V1_SET_INSTANCE_ID, NULL, wl_proxy_get_version((struct wl_proxy *) wp_security_context_v1), 0, instance_id);
+}
+
+/**
+ * @ingroup iface_wp_security_context_v1
+ *
+ * Atomically register the new client and attach the security context
+ * metadata.
+ *
+ * If the provided metadata is inconsistent or does not match with out of
+ * band metadata (see
+ * https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/main/staging/security-context/engines.md),
+ * the invalid_metadata error may be sent eventually.
+ *
+ * It's a protocol error to send any request other than "destroy" after
+ * this request. In this case, the already_used error is sent.
+ */
+static inline void
+wp_security_context_v1_commit(struct wp_security_context_v1 *wp_security_context_v1)
+{
+ wl_proxy_marshal_flags((struct wl_proxy *) wp_security_context_v1,
+ WP_SECURITY_CONTEXT_V1_COMMIT, NULL, wl_proxy_get_version((struct wl_proxy *) wp_security_context_v1), 0);
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/system/wayland/wayland-client-helper.c b/system/wayland/wayland-client-helper.c
new file mode 100644
index 00000000..5a2c9a01
--- /dev/null
+++ b/system/wayland/wayland-client-helper.c
@@ -0,0 +1,96 @@
+#include "wayland-client-helper.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#include <unistd.h>
+
+#include "security-context-v1-protocol.h"
+#include <wayland-client.h>
+
+static void registry_handle_global(void *data, struct wl_registry *registry,
+ uint32_t name, const char *interface,
+ uint32_t version) {
+ struct wp_security_context_manager_v1 **out = data;
+
+ if (strcmp(interface, wp_security_context_manager_v1_interface.name) == 0)
+ *out = wl_registry_bind(registry, name,
+ &wp_security_context_manager_v1_interface, 1);
+}
+
+static void registry_handle_global_remove(void *data,
+ struct wl_registry *registry,
+ uint32_t name) {} /* no-op */
+
+static const struct wl_registry_listener registry_listener = {
+ .global = registry_handle_global,
+ .global_remove = registry_handle_global_remove,
+};
+
+int32_t hakurei_bind_wayland_fd(char *socket_path, int fd, const char *app_id,
+ const char *instance_id, int sync_fd) {
+ int32_t res = 0; /* refer to resErr for corresponding Go error */
+
+ struct wl_display *display;
+ display = wl_display_connect_to_fd(fd);
+ if (!display) {
+ res = 1;
+ goto out;
+ };
+
+ struct wl_registry *registry;
+ registry = wl_display_get_registry(display);
+
+ struct wp_security_context_manager_v1 *security_context_manager = NULL;
+ wl_registry_add_listener(registry, &registry_listener,
+ &security_context_manager);
+ int ret;
+ ret = wl_display_roundtrip(display);
+ wl_registry_destroy(registry);
+ if (ret < 0)
+ goto out;
+
+ if (!security_context_manager) {
+ res = 2;
+ goto out;
+ }
+
+ int listen_fd = -1;
+ listen_fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (listen_fd < 0)
+ goto out;
+
+ struct sockaddr_un sockaddr = {0};
+ sockaddr.sun_family = AF_UNIX;
+ snprintf(sockaddr.sun_path, sizeof(sockaddr.sun_path), "%s", socket_path);
+ if (bind(listen_fd, (struct sockaddr *)&sockaddr, sizeof(sockaddr)) != 0)
+ goto out;
+
+ if (listen(listen_fd, 0) != 0)
+ goto out;
+
+ struct wp_security_context_v1 *security_context;
+ security_context = wp_security_context_manager_v1_create_listener(
+ security_context_manager, listen_fd, sync_fd);
+ wp_security_context_v1_set_sandbox_engine(security_context, "app.hakurei");
+ wp_security_context_v1_set_app_id(security_context, app_id);
+ wp_security_context_v1_set_instance_id(security_context, instance_id);
+ wp_security_context_v1_commit(security_context);
+ wp_security_context_v1_destroy(security_context);
+ if (wl_display_roundtrip(display) < 0)
+ goto out;
+
+out:
+ if (listen_fd >= 0)
+ close(listen_fd);
+ if (security_context_manager)
+ wp_security_context_manager_v1_destroy(security_context_manager);
+ if (display)
+ wl_display_disconnect(display);
+
+ free((void *)socket_path);
+ free((void *)app_id);
+ free((void *)instance_id);
+ return res;
+}
diff --git a/system/wayland/wayland-client-helper.h b/system/wayland/wayland-client-helper.h
new file mode 100644
index 00000000..eae8936d
--- /dev/null
+++ b/system/wayland/wayland-client-helper.h
@@ -0,0 +1,4 @@
+#include <stdint.h>
+
+int32_t hakurei_bind_wayland_fd(char *socket_path, int fd, const char *app_id,
+ const char *instance_id, int sync_fd); \ No newline at end of file
diff --git a/system/wayland/wayland.go b/system/wayland/wayland.go
new file mode 100644
index 00000000..2aa1fe15
--- /dev/null
+++ b/system/wayland/wayland.go
@@ -0,0 +1,36 @@
+package wayland
+
+//go:generate sh -c "wayland-scanner client-header `pkg-config --variable=datarootdir wayland-protocols`/wayland-protocols/staging/security-context/security-context-v1.xml security-context-v1-protocol.h"
+//go:generate sh -c "wayland-scanner private-code `pkg-config --variable=datarootdir wayland-protocols`/wayland-protocols/staging/security-context/security-context-v1.xml security-context-v1-protocol.c"
+
+/*
+#cgo linux pkg-config: --static wayland-client
+#cgo freebsd openbsd LDFLAGS: -lwayland-client
+
+#include "wayland-client-helper.h"
+*/
+import "C"
+import (
+ "errors"
+ "strings"
+)
+
+var (
+ ErrContainsNull = errors.New("string contains null character")
+)
+
+var resErr = [...]error{
+ 0: nil,
+ 1: errors.New("wl_display_connect_to_fd() failed"),
+ 2: errors.New("wp_security_context_v1 not available"),
+}
+
+func bindWaylandFd(socketPath string, fd uintptr, appID, instanceID string, syncFd uintptr) error {
+ if hasNull(appID) || hasNull(instanceID) {
+ return ErrContainsNull
+ }
+ res := C.hakurei_bind_wayland_fd(C.CString(socketPath), C.int(fd), C.CString(appID), C.CString(instanceID), C.int(syncFd))
+ return resErr[int32(res)]
+}
+
+func hasNull(s string) bool { return strings.IndexByte(s, '\x00') > -1 }