aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/system
diff options
context:
space:
mode:
Diffstat (limited to 'internal/system')
-rw-r--r--internal/system/acl.go69
-rw-r--r--internal/system/acl/acl.go33
-rw-r--r--internal/system/acl/acl_test.go276
-rw-r--r--internal/system/acl/libacl-helper.c90
-rw-r--r--internal/system/acl/libacl-helper.go40
-rw-r--r--internal/system/acl/libacl-helper.h4
-rw-r--r--internal/system/acl/libacl-helper_test.go60
-rw-r--r--internal/system/acl/perms.go18
-rw-r--r--internal/system/acl/perms_test.go30
-rw-r--r--internal/system/acl_test.go185
-rw-r--r--internal/system/dbus.go207
-rw-r--r--internal/system/dbus/address.go193
-rw-r--r--internal/system/dbus/address_escape_test.go59
-rw-r--r--internal/system/dbus/address_test.go123
-rw-r--r--internal/system/dbus/config.go71
-rw-r--r--internal/system/dbus/config_test.go124
-rw-r--r--internal/system/dbus/dbus.go69
-rw-r--r--internal/system/dbus/dbus_test.go170
-rw-r--r--internal/system/dbus/export_test.go15
-rw-r--r--internal/system/dbus/proc.go188
-rw-r--r--internal/system/dbus/proc_test.go11
-rw-r--r--internal/system/dbus/proxy.go105
-rw-r--r--internal/system/dbus/samples_test.go214
-rw-r--r--internal/system/dbus/testdata/dev.vencord.Vesktop.json18
-rw-r--r--internal/system/dbus/testdata/org.chromium.Chromium+.json9
-rw-r--r--internal/system/dbus/testdata/org.chromium.Chromium.json24
-rw-r--r--internal/system/dbus/testdata/uk.gensokyo.CrashTestDummy.json21
-rw-r--r--internal/system/dbus_test.go674
-rw-r--r--internal/system/dispatcher.go89
-rw-r--r--internal/system/dispatcher_test.go379
-rw-r--r--internal/system/link.go50
-rw-r--r--internal/system/link_test.go87
-rw-r--r--internal/system/mkdir.go76
-rw-r--r--internal/system/mkdir_test.go113
-rw-r--r--internal/system/output.go88
-rw-r--r--internal/system/output_test.go140
-rw-r--r--internal/system/system.go177
-rw-r--r--internal/system/system_test.go331
-rw-r--r--internal/system/wayland.go90
-rw-r--r--internal/system/wayland/conn.go122
-rw-r--r--internal/system/wayland/security-context-v1-protocol.c74
-rw-r--r--internal/system/wayland/security-context-v1-protocol.h392
-rw-r--r--internal/system/wayland/wayland-client-helper.c96
-rw-r--r--internal/system/wayland/wayland-client-helper.h4
-rw-r--r--internal/system/wayland/wayland.go48
-rw-r--r--internal/system/wayland_test.go303
-rw-r--r--internal/system/xcb/changehosts.go17
-rw-r--r--internal/system/xcb/xcb.go127
-rw-r--r--internal/system/xhost.go38
-rw-r--r--internal/system/xhost_test.go60
50 files changed, 6001 insertions, 0 deletions
diff --git a/internal/system/acl.go b/internal/system/acl.go
new file mode 100644
index 00000000..854e812b
--- /dev/null
+++ b/internal/system/acl.go
@@ -0,0 +1,69 @@
+package system
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "slices"
+
+ "hakurei.app/container/check"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/acl"
+)
+
+// UpdatePerm calls UpdatePermType with the [Process] criteria.
+func (sys *I) UpdatePerm(path *check.Absolute, perms ...acl.Perm) *I {
+ sys.UpdatePermType(Process, path, perms...)
+ return sys
+}
+
+// UpdatePermType maintains [acl.Perms] on a file until its [Enablement] is no longer satisfied.
+func (sys *I) UpdatePermType(et hst.Enablement, path *check.Absolute, perms ...acl.Perm) *I {
+ sys.ops = append(sys.ops, &aclUpdateOp{et, path.String(), perms})
+ return sys
+}
+
+// aclUpdateOp implements [I.UpdatePermType].
+type aclUpdateOp struct {
+ et hst.Enablement
+ path string
+ perms acl.Perms
+}
+
+func (a *aclUpdateOp) Type() hst.Enablement { return a.et }
+
+func (a *aclUpdateOp) apply(sys *I) error {
+ sys.msg.Verbose("applying ACL", a)
+ return newOpError("acl", sys.aclUpdate(a.path, sys.uid, a.perms...), false)
+}
+
+func (a *aclUpdateOp) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(a.Type()) {
+ sys.msg.Verbose("stripping ACL", a)
+ err := sys.aclUpdate(a.path, sys.uid)
+ if errors.Is(err, os.ErrNotExist) {
+ // the ACL is effectively stripped if the file no longer exists
+ sys.msg.Verbosef("target of ACL %s no longer exists", a)
+ err = nil
+ }
+ return newOpError("acl", err, true)
+ } else {
+ sys.msg.Verbose("skipping ACL", a)
+ return nil
+ }
+}
+
+func (a *aclUpdateOp) Is(o Op) bool {
+ target, ok := o.(*aclUpdateOp)
+ return ok && a != nil && target != nil &&
+ a.et == target.et &&
+ a.path == target.path &&
+ slices.Equal(a.perms, target.perms)
+}
+
+func (a *aclUpdateOp) Path() string { return a.path }
+
+func (a *aclUpdateOp) String() string {
+ return fmt.Sprintf("%s type: %s path: %q",
+ a.perms, TypeString(a.et), a.path)
+}
diff --git a/internal/system/acl/acl.go b/internal/system/acl/acl.go
new file mode 100644
index 00000000..366ad533
--- /dev/null
+++ b/internal/system/acl/acl.go
@@ -0,0 +1,33 @@
+// 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)),
+ )
+ return newAclPathError(name, int(r), err)
+}
diff --git a/internal/system/acl/acl_test.go b/internal/system/acl/acl_test.go
new file mode 100644
index 00000000..b0dd58d7
--- /dev/null
+++ b/internal/system/acl/acl_test.go
@@ -0,0 +1,276 @@
+package acl_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path"
+ "reflect"
+ "strconv"
+ "testing"
+
+ "hakurei.app/internal/system/acl"
+)
+
+const testFileName = "acl.test"
+
+var (
+ uid = os.Geteuid()
+ cred = int32(os.Geteuid())
+)
+
+func TestUpdate(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("Update: error = %v", err)
+ }
+ if cur = getfacl(t, testFilePath); len(cur) != 4 {
+ t.Fatalf("Update: %v", cur)
+ }
+ })
+
+ t.Run("default clear consistency", func(t *testing.T) {
+ if err := acl.Update(testFilePath, uid); err != nil {
+ t.Fatalf("Update: error = %v", err)
+ }
+ if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) {
+ t.Fatalf("Update: %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("Update: error = %v", err)
+ }
+ if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) {
+ t.Fatalf("Update: %v, want %v", v, cur)
+ }
+ })
+
+ if err := acl.Update(testFilePath, uid, perms...); err != nil {
+ t.Fatalf("Update: error = %v", err)
+ }
+ r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred)
+ if r == nil {
+ t.Fatalf("Update did not add an ACL entry")
+ }
+ if !r.equals(fAclTypeUser, cred, val) {
+ t.Fatalf("Update(%s) = %s", name, r)
+ }
+ })
+}
+
+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
+}
+
+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/internal/system/acl/libacl-helper.c b/internal/system/acl/libacl-helper.c
new file mode 100644
index 00000000..d1df8167
--- /dev/null
+++ b/internal/system/acl/libacl-helper.c
@@ -0,0 +1,90 @@
+#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;
+ bool v;
+ int i;
+ acl_t acl;
+ acl_entry_t entry;
+ acl_tag_t tag_type;
+ void *qualifier_p;
+ acl_permset_t permset;
+
+ ret = -1; /* acl_get_file */
+ 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)) {
+ ret = -2; /* acl_get_tag_type */
+ if (acl_get_tag_type(entry, &tag_type) != 0)
+ goto out;
+ if (tag_type != ACL_USER)
+ continue;
+
+ ret = -3; /* acl_get_qualifier */
+ qualifier_p = acl_get_qualifier(entry);
+ if (qualifier_p == NULL)
+ goto out;
+ v = *(uid_t *)qualifier_p == uid;
+ acl_free(qualifier_p);
+
+ if (!v)
+ continue;
+
+ ret = -4; /* acl_delete_entry */
+ if (acl_delete_entry(acl, entry) != 0)
+ goto out;
+ }
+
+ if (plen == 0)
+ goto set;
+
+ ret = -5; /* acl_create_entry */
+ if (acl_create_entry(&acl, &entry) != 0)
+ goto out;
+
+ ret = -6; /* acl_get_permset */
+ if (acl_get_permset(entry, &permset) != 0)
+ goto out;
+
+ ret = -7; /* acl_add_perm */
+ for (i = 0; i < plen; i++) {
+ if (acl_add_perm(permset, perms[i]) != 0)
+ goto out;
+ }
+
+ ret = -8; /* acl_set_tag_type */
+ if (acl_set_tag_type(entry, ACL_USER) != 0)
+ goto out;
+
+ ret = -9; /* acl_set_qualifier */
+ if (acl_set_qualifier(entry, (void *)&uid) != 0)
+ goto out;
+
+set:
+ ret = -10; /* acl_calc_mask */
+ if (acl_calc_mask(&acl) != 0)
+ goto out;
+
+ ret = -11; /* acl_valid */
+ if (acl_valid(acl) != 0)
+ goto out;
+
+ ret = -12; /* acl_set_file */
+ 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/internal/system/acl/libacl-helper.go b/internal/system/acl/libacl-helper.go
new file mode 100644
index 00000000..22575296
--- /dev/null
+++ b/internal/system/acl/libacl-helper.go
@@ -0,0 +1,40 @@
+package acl
+
+import "os"
+
+func newAclPathError(name string, r int, err error) error {
+ pathError := &os.PathError{Path: name, Err: err}
+ switch r {
+ case 0:
+ return nil
+
+ case -1:
+ pathError.Op = "acl_get_file"
+ case -2:
+ pathError.Op = "acl_get_tag_type"
+ case -3:
+ pathError.Op = "acl_get_qualifier"
+ case -4:
+ pathError.Op = "acl_delete_entry"
+ case -5:
+ pathError.Op = "acl_create_entry"
+ case -6:
+ pathError.Op = "acl_get_permset"
+ case -7:
+ pathError.Op = "acl_add_perm"
+ case -8:
+ pathError.Op = "acl_set_tag_type"
+ case -9:
+ pathError.Op = "acl_set_qualifier"
+ case -10:
+ pathError.Op = "acl_calc_mask"
+ case -11:
+ pathError.Op = "acl_valid"
+ case -12:
+ pathError.Op = "acl_set_file"
+
+ default: // unreachable
+ pathError.Op = "setfacl"
+ }
+ return pathError
+}
diff --git a/internal/system/acl/libacl-helper.h b/internal/system/acl/libacl-helper.h
new file mode 100644
index 00000000..b86eb170
--- /dev/null
+++ b/internal/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/internal/system/acl/libacl-helper_test.go b/internal/system/acl/libacl-helper_test.go
new file mode 100644
index 00000000..13d23a3c
--- /dev/null
+++ b/internal/system/acl/libacl-helper_test.go
@@ -0,0 +1,60 @@
+package acl
+
+import (
+ "os"
+ "reflect"
+ "syscall"
+ "testing"
+
+ "hakurei.app/container"
+)
+
+func TestNewAclPathError(t *testing.T) {
+ testCases := []struct {
+ name string
+ path string
+ r int
+ err error
+ want error
+ }{
+ {"nil", container.Nonexistent, 0, syscall.ENOTRECOVERABLE, nil},
+
+ {"acl_get_file", container.Nonexistent, -1, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_get_file", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_get_tag_type", container.Nonexistent, -2, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_get_tag_type", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_get_qualifier", container.Nonexistent, -3, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_get_qualifier", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_delete_entry", container.Nonexistent, -4, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_delete_entry", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_create_entry", container.Nonexistent, -5, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_create_entry", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_get_permset", container.Nonexistent, -6, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_get_permset", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_add_perm", container.Nonexistent, -7, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_add_perm", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_set_tag_type", container.Nonexistent, -8, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_set_tag_type", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_set_qualifier", container.Nonexistent, -9, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_set_qualifier", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_calc_mask", container.Nonexistent, -10, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_calc_mask", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_valid", container.Nonexistent, -11, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_valid", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"acl_set_file", container.Nonexistent, -12, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "acl_set_file", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+
+ {"acl", container.Nonexistent, -13, syscall.ENOTRECOVERABLE,
+ &os.PathError{Op: "setfacl", Path: container.Nonexistent, Err: syscall.ENOTRECOVERABLE}},
+ {"invalid", container.Nonexistent, -0xdead, nil,
+ &os.PathError{Op: "setfacl", Path: container.Nonexistent}},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := newAclPathError(tc.path, tc.r, tc.err)
+ if !reflect.DeepEqual(err, tc.want) {
+ t.Errorf("newAclPathError: %v, want %v", err, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/system/acl/perms.go b/internal/system/acl/perms.go
new file mode 100644
index 00000000..fbc99e82
--- /dev/null
+++ b/internal/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/internal/system/acl/perms_test.go b/internal/system/acl/perms_test.go
new file mode 100644
index 00000000..3d5e3df5
--- /dev/null
+++ b/internal/system/acl/perms_test.go
@@ -0,0 +1,30 @@
+package acl_test
+
+import (
+ "testing"
+
+ "hakurei.app/internal/system/acl"
+)
+
+func TestPerms(t *testing.T) {
+ testCases := []struct {
+ name string
+ perms acl.Perms
+ }{
+ {"---", acl.Perms{}},
+ {"r--", acl.Perms{acl.Read}},
+ {"-w-", acl.Perms{acl.Write}},
+ {"--x", acl.Perms{acl.Execute}},
+ {"rw-", acl.Perms{acl.Read, acl.Read, acl.Write}},
+ {"r-x", acl.Perms{acl.Read, acl.Execute, acl.Execute}},
+ {"-wx", acl.Perms{acl.Write, acl.Write, acl.Execute, acl.Execute}},
+ {"rwx", acl.Perms{acl.Read, acl.Write, acl.Execute}},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.perms.String(); got != tc.name {
+ t.Errorf("String: %q, want %q", got, tc.name)
+ }
+ })
+ }
+}
diff --git a/internal/system/acl_test.go b/internal/system/acl_test.go
new file mode 100644
index 00000000..1fc3b1a2
--- /dev/null
+++ b/internal/system/acl_test.go
@@ -0,0 +1,185 @@
+package system
+
+import (
+ "os"
+ "syscall"
+ "testing"
+
+ "hakurei.app/container/stub"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/acl"
+)
+
+func TestACLUpdateOp(t *testing.T) {
+ t.Parallel()
+
+ checkOpBehaviour(t, []opBehaviourTestCase{
+ {"apply aclUpdate", 0xbeef, 0xff,
+ &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"applying ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, stub.UniqueError(1)),
+ }, &OpError{Op: "acl", Err: stub.UniqueError(1)}, nil, nil},
+
+ {"revert aclUpdate", 0xbeef, 0xff,
+ &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"applying ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"stripping ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, ([]acl.Perm)(nil)}, nil, stub.UniqueError(0)),
+ }, &OpError{Op: "acl", Err: stub.UniqueError(0), Revert: true}},
+
+ {"success revert skip", 0xbeef, Process,
+ &aclUpdateOp{User, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"applying ACL", &aclUpdateOp{User, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"skipping ACL", &aclUpdateOp{User, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ }, nil},
+
+ {"success revert aclUpdate ENOENT", 0xbeef, 0xff,
+ &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"applying ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"stripping ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, ([]acl.Perm)(nil)}, nil, &os.PathError{Op: "acl_get_file", Path: "/proc/nonexistent", Err: syscall.ENOENT}),
+ call("verbosef", stub.ExpectArgs{"target of ACL %s no longer exists", []any{&aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ }, nil},
+
+ {"success", 0xbeef, 0xff,
+ &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"applying ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"stripping ACL", &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{acl.Read, acl.Write, acl.Execute}}}}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/proc/nonexistent", 0xbeef, ([]acl.Perm)(nil)}, nil, nil),
+ }, nil},
+ })
+
+ checkOpsBuilder(t, "UpdatePermType", []opsBuilderTestCase{
+ {"simple",
+ 0xbeef,
+ func(_ *testing.T, sys *I) {
+ sys.
+ UpdatePerm(m("/run/user/1971/hakurei"), acl.Execute).
+ UpdatePerm(m("/tmp/hakurei.0/tmpdir/150"), acl.Read, acl.Write, acl.Execute)
+ }, []Op{
+ &aclUpdateOp{Process, "/run/user/1971/hakurei", []acl.Perm{acl.Execute}},
+ &aclUpdateOp{Process, "/tmp/hakurei.0/tmpdir/150", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
+ }, stub.Expect{}},
+
+ {"tmpdirp", 0xbeef, func(_ *testing.T, sys *I) {
+ sys.UpdatePermType(User, m("/tmp/hakurei.0/tmpdir"), acl.Execute)
+ }, []Op{
+ &aclUpdateOp{User, "/tmp/hakurei.0/tmpdir", []acl.Perm{acl.Execute}},
+ }, stub.Expect{}},
+
+ {"tmpdir", 0xbeef, func(_ *testing.T, sys *I) {
+ sys.UpdatePermType(User, m("/tmp/hakurei.0/tmpdir/150"), acl.Read, acl.Write, acl.Execute)
+ }, []Op{
+ &aclUpdateOp{User, "/tmp/hakurei.0/tmpdir/150", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
+ }, stub.Expect{}},
+
+ {"share", 0xbeef, func(_ *testing.T, sys *I) {
+ sys.UpdatePermType(Process, m("/run/user/1971/hakurei/fcb8a12f7c482d183ade8288c3de78b5"), acl.Execute)
+ }, []Op{
+ &aclUpdateOp{Process, "/run/user/1971/hakurei/fcb8a12f7c482d183ade8288c3de78b5", []acl.Perm{acl.Execute}},
+ }, stub.Expect{}},
+
+ {"passwd", 0xbeef, func(_ *testing.T, sys *I) {
+ sys.
+ UpdatePermType(Process, m("/tmp/hakurei.0/fcb8a12f7c482d183ade8288c3de78b5/passwd"), acl.Read).
+ UpdatePermType(Process, m("/tmp/hakurei.0/fcb8a12f7c482d183ade8288c3de78b5/group"), acl.Read)
+ }, []Op{
+ &aclUpdateOp{Process, "/tmp/hakurei.0/fcb8a12f7c482d183ade8288c3de78b5/passwd", []acl.Perm{acl.Read}},
+ &aclUpdateOp{Process, "/tmp/hakurei.0/fcb8a12f7c482d183ade8288c3de78b5/group", []acl.Perm{acl.Read}},
+ }, stub.Expect{}},
+
+ {"wayland", 0xbeef, func(_ *testing.T, sys *I) {
+ sys.UpdatePermType(hst.EWayland, m("/run/user/1971/wayland-0"), acl.Read, acl.Write, acl.Execute)
+ }, []Op{
+ &aclUpdateOp{hst.EWayland, "/run/user/1971/wayland-0", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
+ }, stub.Expect{}},
+ })
+
+ checkOpIs(t, []opIsTestCase{
+ {"nil", (*aclUpdateOp)(nil), (*aclUpdateOp)(nil), false},
+ {"zero", new(aclUpdateOp), new(aclUpdateOp), true},
+
+ {"et differs",
+ &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, &aclUpdateOp{
+ hst.EX11, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, false},
+
+ {"path differs", &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-1",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, false},
+
+ {"perms differs", &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write},
+ }, false},
+
+ {"equals", &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, &aclUpdateOp{
+ hst.EWayland, "/run/user/1971/wayland-0",
+ []acl.Perm{acl.Read, acl.Write, acl.Execute},
+ }, true},
+ })
+
+ checkOpMeta(t, []opMetaTestCase{
+ {"clear",
+ &aclUpdateOp{Process, "/proc/nonexistent", []acl.Perm{}},
+ Process, "/proc/nonexistent",
+ `--- type: process path: "/proc/nonexistent"`},
+
+ {"read",
+ &aclUpdateOp{User, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/0", []acl.Perm{acl.Read}},
+ User, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/0",
+ `r-- type: user path: "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/0"`},
+
+ {"write",
+ &aclUpdateOp{User, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/1", []acl.Perm{acl.Write}},
+ User, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/1",
+ `-w- type: user path: "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/1"`},
+
+ {"execute",
+ &aclUpdateOp{User, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/2", []acl.Perm{acl.Execute}},
+ User, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/2",
+ `--x type: user path: "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/2"`},
+
+ {"wayland",
+ &aclUpdateOp{hst.EWayland, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/wayland", []acl.Perm{acl.Read, acl.Write}},
+ hst.EWayland, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/wayland",
+ `rw- type: wayland path: "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/wayland"`},
+
+ {"x11",
+ &aclUpdateOp{hst.EX11, "/tmp/.X11-unix/X0", []acl.Perm{acl.Read, acl.Execute}},
+ hst.EX11, "/tmp/.X11-unix/X0",
+ `r-x type: x11 path: "/tmp/.X11-unix/X0"`},
+
+ {"dbus",
+ &aclUpdateOp{hst.EDBus, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/bus", []acl.Perm{acl.Write, acl.Execute}},
+ hst.EDBus, "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/bus",
+ `-wx type: dbus path: "/tmp/hakurei.0/27d81d567f8fae7f33278eec45da9446/bus"`},
+
+ {"pulseaudio",
+ &aclUpdateOp{hst.EPulse, "/run/user/1971/hakurei/27d81d567f8fae7f33278eec45da9446/pulse", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
+ hst.EPulse, "/run/user/1971/hakurei/27d81d567f8fae7f33278eec45da9446/pulse",
+ `rwx type: pulseaudio path: "/run/user/1971/hakurei/27d81d567f8fae7f33278eec45da9446/pulse"`},
+ })
+}
diff --git a/internal/system/dbus.go b/internal/system/dbus.go
new file mode 100644
index 00000000..b9021b81
--- /dev/null
+++ b/internal/system/dbus.go
@@ -0,0 +1,207 @@
+package system
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "reflect"
+ "strings"
+ "sync"
+ "syscall"
+
+ "hakurei.app/container"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/dbus"
+)
+
+// ErrDBusConfig is returned when a required [hst.BusConfig] argument is nil.
+var ErrDBusConfig = errors.New("dbus config not supplied")
+
+// MustProxyDBus calls ProxyDBus and panics if an error is returned.
+func (sys *I) MustProxyDBus(
+ session, system *hst.BusConfig,
+ sessionBus, systemBus dbus.ProxyPair,
+) *I {
+ if err := sys.ProxyDBus(session, system, sessionBus, systemBus); err != nil {
+ panic(err.Error())
+ } else {
+ return sys
+ }
+}
+
+// ProxyDBus finalises configuration ahead of time and starts xdg-dbus-proxy via [dbus] and terminates it on revert.
+// This [Op] is always [Process] scoped.
+func (sys *I) ProxyDBus(
+ session, system *hst.BusConfig,
+ sessionBus, systemBus dbus.ProxyPair,
+) error {
+ d := new(dbusProxyOp)
+
+ // session bus is required as otherwise this is effectively a very expensive noop
+ if session == nil {
+ return newOpErrorMessage("dbus", ErrDBusConfig,
+ "attempted to create message bus proxy args without session bus config", false)
+ }
+
+ // system bus is optional
+ d.system = system != nil
+
+ d.out = &linePrefixWriter{println: log.Println, prefix: "(dbus) ", buf: new(strings.Builder)}
+ if final, err := sys.dbusFinalise(sessionBus, systemBus, session, system); err != nil {
+ if errors.Is(err, syscall.EINVAL) {
+ return newOpErrorMessage("dbus", err,
+ "message bus proxy configuration contains NUL byte", false)
+ }
+ return newOpErrorMessage("dbus", err,
+ fmt.Sprintf("cannot finalise message bus proxy: %v", err), false)
+ } else {
+ if sys.msg.IsVerbose() {
+ sys.msg.Verbose("session bus proxy:", dbus.Args(session, sessionBus))
+ if system != nil {
+ sys.msg.Verbose("system bus proxy:", dbus.Args(system, systemBus))
+ }
+
+ // this calls the argsWt String method
+ sys.msg.Verbose("message bus proxy final args:", final.WriterTo)
+ }
+
+ d.final = final
+ }
+
+ sys.ops = append(sys.ops, d)
+ return nil
+}
+
+// dbusProxyOp implements [I.ProxyDBus].
+type dbusProxyOp struct {
+ proxy *dbus.Proxy // populated during apply
+
+ final *dbus.Final
+ out *linePrefixWriter
+ // whether system bus proxy is enabled
+ system bool
+}
+
+func (d *dbusProxyOp) Type() hst.Enablement { return Process }
+
+func (d *dbusProxyOp) apply(sys *I) error {
+ sys.msg.Verbosef("session bus proxy on %q for upstream %q", d.final.Session[1], d.final.Session[0])
+ if d.system {
+ sys.msg.Verbosef("system bus proxy on %q for upstream %q", d.final.System[1], d.final.System[0])
+ }
+
+ d.proxy = dbus.New(sys.ctx, sys.msg, d.final, d.out)
+ if err := sys.dbusProxyStart(d.proxy); err != nil {
+ d.out.Dump()
+ return newOpErrorMessage("dbus", err,
+ fmt.Sprintf("cannot start message bus proxy: %v", err), false)
+ }
+ sys.msg.Verbose("starting message bus proxy", d.proxy)
+ return nil
+}
+
+func (d *dbusProxyOp) revert(sys *I, _ *Criteria) error {
+ // criteria ignored here since dbus is always process-scoped
+ sys.msg.Verbose("terminating message bus proxy")
+ sys.dbusProxyClose(d.proxy)
+
+ exitMessage := "message bus proxy exit"
+ defer func() { sys.msg.Verbose(exitMessage) }()
+
+ if d.out != nil {
+ d.out.Dump()
+ }
+
+ err := sys.dbusProxyWait(d.proxy)
+ if errors.Is(err, context.Canceled) {
+ exitMessage = "message bus proxy canceled upstream"
+ err = nil
+ }
+ return newOpErrorMessage("dbus", err,
+ fmt.Sprintf("message bus proxy error: %v", err), true)
+}
+
+func (d *dbusProxyOp) Is(o Op) bool {
+ target, ok := o.(*dbusProxyOp)
+ return ok && d != nil && target != nil &&
+ d.system == target.system &&
+ d.final != nil && target.final != nil &&
+ d.final.Session == target.final.Session &&
+ d.final.System == target.final.System &&
+ dbus.EqualAddrEntries(d.final.SessionUpstream, target.final.SessionUpstream) &&
+ dbus.EqualAddrEntries(d.final.SystemUpstream, target.final.SystemUpstream) &&
+ reflect.DeepEqual(d.final.WriterTo, target.final.WriterTo)
+}
+
+func (d *dbusProxyOp) Path() string { return container.Nonexistent }
+func (d *dbusProxyOp) String() string { return d.proxy.String() }
+
+const (
+ // lpwSizeThreshold is the threshold of bytes written to linePrefixWriter which,
+ // if reached or exceeded, causes linePrefixWriter to drop all future writes.
+ lpwSizeThreshold = 1 << 24
+)
+
+// linePrefixWriter calls println with a prefix for every line written.
+type linePrefixWriter struct {
+ prefix string
+ println func(v ...any)
+
+ n int
+ msg []string
+ buf *strings.Builder
+
+ mu sync.RWMutex
+}
+
+func (s *linePrefixWriter) Write(p []byte) (n int, err error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.write(p, 0)
+}
+
+func (s *linePrefixWriter) write(p []byte, a int) (int, error) {
+ if s.n >= lpwSizeThreshold {
+ if len(p) == 0 {
+ return a, nil
+ }
+ return a, syscall.ENOMEM
+ }
+
+ if i := bytes.IndexByte(p, '\n'); i == -1 {
+ n, _ := s.buf.Write(p)
+ s.n += n
+ return a + n, nil
+ } else {
+ n, _ := s.buf.Write(p[:i])
+ s.n += n + 1
+
+ v := s.buf.String()
+ if strings.HasPrefix(v, "init: ") {
+ s.n -= len(v) + 1
+ // pass through container init messages
+ s.println(s.prefix + v)
+ } else {
+ s.msg = append(s.msg, v)
+ }
+
+ s.buf.Reset()
+ return s.write(p[i+1:], a+n+1)
+ }
+}
+
+func (s *linePrefixWriter) Dump() {
+ s.mu.RLock()
+ for _, m := range s.msg {
+ s.println(s.prefix + m)
+ }
+ if s.buf != nil && s.buf.Len() != 0 {
+ s.println("*" + s.prefix + s.buf.String())
+ }
+ if s.n >= lpwSizeThreshold {
+ s.println("+" + s.prefix + "write threshold reached, output may be incomplete")
+ }
+ s.mu.RUnlock()
+}
diff --git a/internal/system/dbus/address.go b/internal/system/dbus/address.go
new file mode 100644
index 00000000..edee7e20
--- /dev/null
+++ b/internal/system/dbus/address.go
@@ -0,0 +1,193 @@
+package dbus
+
+import (
+ "bytes"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "slices"
+)
+
+type AddrEntry struct {
+ Method string `json:"method"`
+ Values [][2]string `json:"values"`
+}
+
+// EqualAddrEntries returns whether two slices of [AddrEntry] are equal.
+func EqualAddrEntries(entries, target []AddrEntry) bool {
+ return slices.EqualFunc(entries, target, func(a AddrEntry, b AddrEntry) bool {
+ return a.Method == b.Method && slices.Equal(a.Values, b.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/internal/system/dbus/address_escape_test.go b/internal/system/dbus/address_escape_test.go
new file mode 100644
index 00000000..4ef48f8b
--- /dev/null
+++ b/internal/system/dbus/address_escape_test.go
@@ -0,0 +1,59 @@
+package dbus
+
+import (
+ "testing"
+)
+
+func TestUnescapeValue(t *testing.T) {
+ t.Parallel()
+
+ 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) {
+ t.Parallel()
+
+ 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/internal/system/dbus/address_test.go b/internal/system/dbus/address_test.go
new file mode 100644
index 00000000..9ed48214
--- /dev/null
+++ b/internal/system/dbus/address_test.go
@@ -0,0 +1,123 @@
+package dbus_test
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+
+ "hakurei.app/internal/system/dbus"
+)
+
+func TestParse(t *testing.T) {
+ t.Parallel()
+
+ 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) {
+ t.Parallel()
+
+ 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/internal/system/dbus/config.go b/internal/system/dbus/config.go
new file mode 100644
index 00000000..2a09ee90
--- /dev/null
+++ b/internal/system/dbus/config.go
@@ -0,0 +1,71 @@
+package dbus
+
+import (
+ "hakurei.app/hst"
+)
+
+// ProxyPair is an upstream dbus address and a downstream socket path.
+type ProxyPair [2]string
+
+// Args returns the xdg-dbus-proxy arguments equivalent of [hst.BusConfig].
+func Args(c *hst.BusConfig, 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
+}
+
+// NewConfig returns the address of a new [hst.BusConfig] with optional defaults.
+func NewConfig(id string, defaults, mpris bool) *hst.BusConfig {
+ c := hst.BusConfig{
+ 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 &c
+}
diff --git a/internal/system/dbus/config_test.go b/internal/system/dbus/config_test.go
new file mode 100644
index 00000000..dec4cb16
--- /dev/null
+++ b/internal/system/dbus/config_test.go
@@ -0,0 +1,124 @@
+package dbus_test
+
+import (
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/dbus"
+)
+
+func TestConfigArgs(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range testCasesExt {
+ if tc.wantErr {
+ // args does not check for nulls
+ continue
+ }
+
+ t.Run("build arguments for "+tc.id, func(t *testing.T) {
+ t.Parallel()
+
+ if got := dbus.Args(tc.c, tc.bus); !slices.Equal(got, tc.want) {
+ t.Errorf("Args: %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestNewConfig(t *testing.T) {
+ t.Parallel()
+ ids := [...]string{"org.chromium.Chromium", "dev.vencord.Vesktop"}
+
+ type newTestCase struct {
+ id string
+ args [2]bool
+ want *hst.BusConfig
+ }
+
+ // 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}, &hst.BusConfig{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{"", [2]bool{false, true}, &hst.BusConfig{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{"", [2]bool{true, false}, &hst.BusConfig{
+ 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}, &hst.BusConfig{
+ 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}, &hst.BusConfig{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{id, [2]bool{false, true}, &hst.BusConfig{
+ Call: make(map[string]string),
+ Broadcast: make(map[string]string),
+ Filter: true,
+ }},
+ newTestCase{id, [2]bool{true, false}, &hst.BusConfig{
+ 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}, &hst.BusConfig{
+ 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) {
+ t.Parallel()
+
+ 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/internal/system/dbus/dbus.go b/internal/system/dbus/dbus.go
new file mode 100644
index 00000000..9ea89f51
--- /dev/null
+++ b/internal/system/dbus/dbus.go
@@ -0,0 +1,69 @@
+// Package dbus wraps xdg-dbus-proxy and implements configuration and sandboxing of the underlying helper process.
+package dbus
+
+import (
+ "fmt"
+ "os"
+ "sync"
+)
+
+const (
+ /*
+ SessionBusAddress is the name of the environment variable where the address of the login session message bus is given in.
+
+ If that variable is not set, applications may also try to read the address from the X Window System root window property _DBUS_SESSION_BUS_ADDRESS.
+ The root window property must have type STRING. The environment variable should have precedence over the root window property.
+
+ The address of the login session message bus is given in the DBUS_SESSION_BUS_ADDRESS environment variable.
+ If DBUS_SESSION_BUS_ADDRESS is not set, or if it's set to the string "autolaunch:",
+ the system should use platform-specific methods of locating a running D-Bus session server,
+ or starting one if a running instance cannot be found.
+ Note that this mechanism is not recommended for attempting to determine if a daemon is running.
+ It is inherently racy to attempt to make this determination, since the bus daemon may be started just before or just after the determination is made.
+ Therefore, it is recommended that applications do not try to make this determination for their functionality purposes, and instead they should attempt to start the server.
+
+ This package diverges from the specification, as the caller is unlikely to be an X client, or be in a position to autolaunch a dbus server.
+ So a fallback address with a socket located in the well-known default XDG_RUNTIME_DIR formatting is used.
+ */
+ SessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
+
+ /*
+ SystemBusAddress is the name of the environment variable where the address of the system message bus is given in.
+
+ If that variable is not set, applications should try to connect to the well-known address unix:path=/var/run/dbus/system_bus_socket.
+ Implementations of the well-known system bus should listen on an address that will result in that connection being successful.
+ */
+ SystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
+
+ // FallbackSystemBusAddress is used when [SystemBusAddress] is not set.
+ FallbackSystemBusAddress = "unix:path=/var/run/dbus/system_bus_socket"
+)
+
+var (
+ address [2]string
+ addressOnce sync.Once
+)
+
+// Address returns the session and system bus addresses copied from environment,
+// or appropriate fallback values if they are not set.
+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
+ address[0] = fmt.Sprintf("unix:path=/run/user/%d/bus", os.Getuid())
+ } else {
+ address[0] = addr
+ }
+
+ // resolve upstream system bus address
+ if addr, ok := os.LookupEnv(SystemBusAddress); !ok {
+ // fall back to default hardcoded value
+ address[1] = FallbackSystemBusAddress
+ } else {
+ address[1] = addr
+ }
+ })
+
+ return address[0], address[1]
+}
diff --git a/internal/system/dbus/dbus_test.go b/internal/system/dbus/dbus_test.go
new file mode 100644
index 00000000..9fc2b539
--- /dev/null
+++ b/internal/system/dbus/dbus_test.go
@@ -0,0 +1,170 @@
+package dbus_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "hakurei.app/internal/helper"
+ "hakurei.app/internal/system/dbus"
+ "hakurei.app/message"
+)
+
+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) {
+ t.Run("sandbox", func(t *testing.T) { testProxyFinaliseStartWaitCloseString(t, true) })
+ t.Run("direct", func(t *testing.T) { testProxyFinaliseStartWaitCloseString(t, false) })
+}
+
+const (
+ stubProxyTimeout = 5 * time.Second
+)
+
+func testProxyFinaliseStartWaitCloseString(t *testing.T, useSandbox bool) {
+ {
+ oldWaitDelay := helper.WaitDelay
+ helper.WaitDelay = 16 * time.Second
+ t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
+ }
+
+ {
+ proxyName := dbus.ProxyName
+ dbus.ProxyName = os.Args[0]
+ t.Cleanup(func() { dbus.ProxyName = proxyName })
+ }
+
+ 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(), message.New(nil), nil, nil)
+ } else {
+ p = dbus.New(t.Context(), message.New(nil), 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(), stubProxyTimeout)
+ defer cancel()
+ output := new(strings.Builder)
+ if !useSandbox {
+ p = dbus.NewDirect(ctx, message.New(nil), final, output)
+ } else {
+ p = dbus.New(ctx, message.New(nil), final, output)
+ }
+
+ { // check invalid wait behaviour
+ wantErr := "dbus: not started"
+ if err := p.Wait(); err == nil || err.Error() != wantErr {
+ t.Errorf("Wait: error = %v, wantErr %v", err, wantErr)
+ }
+ }
+
+ { // check string behaviour
+ want := "(unused dbus proxy)"
+ if got := p.String(); got != want {
+ t.Errorf("String: %q, want %q", got, want)
+ return
+ }
+ }
+
+ if err := p.Start(); err != nil {
+ t.Fatalf("Start: error = %v", err)
+ }
+
+ { // check running string behaviour
+ wantSubstr := fmt.Sprintf("%s --args=3 --fd=4", os.Args[0])
+ if useSandbox {
+ wantSubstr = `argv: ["xdg-dbus-proxy" "--args=3" "--fd=4"], filter: true, rules: 0, flags: 0x1, presets: 0xf`
+ }
+ if got := p.String(); !strings.Contains(got, wantSubstr) {
+ t.Errorf("String: %q, want %q",
+ got, wantSubstr)
+ return
+ }
+ }
+
+ p.Close()
+ if err := p.Wait(); err != nil {
+ t.Errorf("Wait: error = %v\noutput: %s", err, output.String())
+ }
+ })
+ }
+}
diff --git a/internal/system/dbus/export_test.go b/internal/system/dbus/export_test.go
new file mode 100644
index 00000000..bc24f3f3
--- /dev/null
+++ b/internal/system/dbus/export_test.go
@@ -0,0 +1,15 @@
+package dbus
+
+import (
+ "context"
+ "io"
+
+ "hakurei.app/message"
+)
+
+// NewDirect returns a new instance of [Proxy] with its sandbox disabled.
+func NewDirect(ctx context.Context, msg message.Msg, final *Final, output io.Writer) *Proxy {
+ p := New(ctx, msg, final, output)
+ p.useSandbox = false
+ return p
+}
diff --git a/internal/system/dbus/proc.go b/internal/system/dbus/proc.go
new file mode 100644
index 00000000..0bd9c6b3
--- /dev/null
+++ b/internal/system/dbus/proc.go
@@ -0,0 +1,188 @@
+package dbus
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "strconv"
+ "syscall"
+
+ "hakurei.app/container"
+ "hakurei.app/container/check"
+ "hakurei.app/container/seccomp"
+ "hakurei.app/container/std"
+ "hakurei.app/internal/helper"
+ "hakurei.app/ldd"
+)
+
+// 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.output != nil {
+ cmd.Stdout, cmd.Stderr = p.output, p.output
+ }
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Env = make([]string, 0)
+ }, nil)
+ } else {
+ var toolPath *check.Absolute
+ if a, err := check.NewAbs(p.name); err != nil {
+ if p.name, err = exec.LookPath(p.name); err != nil {
+ return err
+ } else if toolPath, err = check.NewAbs(p.name); err != nil {
+ return err
+ }
+ } else {
+ toolPath = a
+ }
+
+ var libPaths []*check.Absolute
+ if entries, err := ldd.Exec(ctx, p.msg, toolPath.String()); err != nil {
+ return err
+ } else {
+ libPaths = ldd.Path(entries)
+ }
+
+ p.helper = helper.New(
+ ctx, p.msg, toolPath, "xdg-dbus-proxy",
+ p.final, true,
+ argF, func(z *container.Container) {
+ z.SeccompFlags |= seccomp.AllowMultiarch
+ z.SeccompPresets |= std.PresetStrict
+ z.Hostname = "hakurei-dbus"
+ if p.output != nil {
+ z.Stdout, z.Stderr = p.output, p.output
+ }
+
+ // these lib paths are unpredictable, so mount them first so they cannot cover anything
+ for _, name := range libPaths {
+ z.Bind(name, name, 0)
+ }
+
+ // upstream bus directories
+ upstreamPaths := make([]*check.Absolute, 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" {
+ continue
+ }
+ if a, err := check.NewAbs(pair[1]); err != nil {
+ continue
+ } else {
+ upstreamPaths = append(upstreamPaths, a.Dir())
+ }
+ }
+ }
+ }
+ check.SortAbs(upstreamPaths)
+ upstreamPaths = check.CompactAbs(upstreamPaths)
+ for _, name := range upstreamPaths {
+ z.Bind(name, name, 0)
+ }
+ z.HostNet = len(upstreamPaths) == 0
+ z.HostAbstract = z.HostNet
+
+ // parent directories of bind paths
+ sockDirPaths := make([]*check.Absolute, 0, 2)
+ if a, err := check.NewAbs(p.final.Session[1]); err == nil {
+ sockDirPaths = append(sockDirPaths, a.Dir())
+ }
+ if a, err := check.NewAbs(p.final.System[1]); err == nil {
+ sockDirPaths = append(sockDirPaths, a.Dir())
+ }
+ check.SortAbs(sockDirPaths)
+ sockDirPaths = check.CompactAbs(sockDirPaths)
+ for _, name := range sockDirPaths {
+ z.Bind(name, name, std.BindWritable)
+ }
+
+ // xdg-dbus-proxy bin path
+ binPath := toolPath.Dir()
+ z.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")
+ }
+
+ var errs [3]error
+
+ 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/internal/system/dbus/proc_test.go b/internal/system/dbus/proc_test.go
new file mode 100644
index 00000000..544856ed
--- /dev/null
+++ b/internal/system/dbus/proc_test.go
@@ -0,0 +1,11 @@
+package dbus_test
+
+import (
+ "os"
+ "testing"
+
+ "hakurei.app/container"
+ "hakurei.app/internal/helper"
+)
+
+func TestMain(m *testing.M) { container.TryArgv0(nil); helper.InternalHelperStub(); os.Exit(m.Run()) }
diff --git a/internal/system/dbus/proxy.go b/internal/system/dbus/proxy.go
new file mode 100644
index 00000000..9de49c41
--- /dev/null
+++ b/internal/system/dbus/proxy.go
@@ -0,0 +1,105 @@
+package dbus
+
+import (
+ "context"
+ "io"
+ "sync"
+ "syscall"
+
+ "hakurei.app/hst"
+ "hakurei.app/internal/helper"
+ "hakurei.app/message"
+)
+
+// 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"
+
+// Proxy holds the state of a xdg-dbus-proxy process, and should never be copied.
+type Proxy struct {
+ helper helper.Helper
+ ctx context.Context
+ msg message.Msg
+
+ cancel context.CancelCauseFunc
+ cause func() error
+
+ final *Final
+ output io.Writer
+ useSandbox bool
+
+ name string
+
+ 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 *hst.BusConfig) (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, Args(session, sessionBus)...)
+ }
+ if system != nil {
+ if err = system.CheckInterfaces("system"); err != nil {
+ return
+ }
+ args = append(args, Args(system, 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, msg message.Msg, final *Final, output io.Writer) *Proxy {
+ return &Proxy{name: ProxyName, ctx: ctx, msg: msg, final: final, output: output, useSandbox: true}
+}
diff --git a/internal/system/dbus/samples_test.go b/internal/system/dbus/samples_test.go
new file mode 100644
index 00000000..7db3d6b9
--- /dev/null
+++ b/internal/system/dbus/samples_test.go
@@ -0,0 +1,214 @@
+package dbus_test
+
+import (
+ "hakurei.app/hst"
+)
+
+const (
+ sampleHostPath = "/tmp/bus"
+ sampleHostAddr = "unix:path=" + sampleHostPath
+ sampleBindPath = "/tmp/proxied_bus"
+)
+
+var samples = []dbusTestCase{
+ {
+ "org.chromium.Chromium", &hst.BusConfig{
+ 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+", &hst.BusConfig{
+ 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", &hst.BusConfig{
+ 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", &hst.BusConfig{
+ 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", &hst.BusConfig{
+ 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 *hst.BusConfig
+ wantErr bool
+ wantErrF bool
+ bus [2]string
+ want []string
+}
+
+var (
+ testCasesExt = func() []dbusTestCase {
+ testCases := make([]dbusTestCase, len(samples)*2)
+ for i := range samples {
+ testCases[i] = samples[i]
+
+ fi := &testCases[len(samples)+i]
+ *fi = samples[i]
+
+ // create null-injected test cases
+ fi.wantErr = true
+ injectNulls := func(t *[]string) {
+ f := make([]string, len(*t))
+ for i := range f {
+ f[i] = "\x00" + (*t)[i] + "\x00"
+ }
+ *t = f
+ }
+
+ fi.c = new(hst.BusConfig)
+ *fi.c = *samples[i].c
+ injectNulls(&fi.c.See)
+ injectNulls(&fi.c.Talk)
+ injectNulls(&fi.c.Own)
+ }
+ return testCases
+ }()
+
+ testCasePairs = func() map[string][2]dbusTestCase {
+ // enumerate test case pairs
+ var pc int
+ for _, tc := range samples {
+ if tc.id != "" {
+ pc++
+ }
+ }
+ pairs := make(map[string][2]dbusTestCase, pc)
+ for i, tc := range testCasesExt {
+ 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(testCasesExt) && testCasesExt[i+1].id[len(testCasesExt[i+1].id)-1] == '+' {
+ // attach system bus config
+ ftp[1] = testCasesExt[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
+ }
+ pairs[k] = ftp
+ }
+ return pairs
+ }()
+)
diff --git a/internal/system/dbus/testdata/dev.vencord.Vesktop.json b/internal/system/dbus/testdata/dev.vencord.Vesktop.json
new file mode 100644
index 00000000..05259774
--- /dev/null
+++ b/internal/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/internal/system/dbus/testdata/org.chromium.Chromium+.json b/internal/system/dbus/testdata/org.chromium.Chromium+.json
new file mode 100644
index 00000000..0a27d719
--- /dev/null
+++ b/internal/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/internal/system/dbus/testdata/org.chromium.Chromium.json b/internal/system/dbus/testdata/org.chromium.Chromium.json
new file mode 100644
index 00000000..be027f39
--- /dev/null
+++ b/internal/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/internal/system/dbus/testdata/uk.gensokyo.CrashTestDummy.json b/internal/system/dbus/testdata/uk.gensokyo.CrashTestDummy.json
new file mode 100644
index 00000000..43272f67
--- /dev/null
+++ b/internal/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/internal/system/dbus_test.go b/internal/system/dbus_test.go
new file mode 100644
index 00000000..f79c8482
--- /dev/null
+++ b/internal/system/dbus_test.go
@@ -0,0 +1,674 @@
+package system
+
+import (
+ "context"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+
+ "hakurei.app/container/stub"
+ "hakurei.app/hst"
+ "hakurei.app/internal/helper"
+ "hakurei.app/internal/system/dbus"
+)
+
+func TestDBusProxyOp(t *testing.T) {
+ t.Parallel()
+
+ checkOpBehaviour(t, []opBehaviourTestCase{
+ {"dbusProxyStart", 0xdead, 0xff, &dbusProxyOp{
+ final: dbusNewFinalSample(4),
+ out: new(linePrefixWriter), // panics on write
+ system: true,
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"session bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus", "unix:path=/run/user/1000/bus"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"system bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket", "unix:path=/run/dbus/system_bus_socket"}}, nil, nil),
+ call("dbusProxyStart", stub.ExpectArgs{dbusNewFinalSample(4)}, nil, stub.UniqueError(2)),
+ }, &OpError{
+ Op: "dbus", Err: stub.UniqueError(2),
+ Msg: "cannot start message bus proxy: unique error 2 injected by the test suite",
+ }, nil, nil},
+
+ {"dbusProxyWait", 0xdead, 0xff, &dbusProxyOp{
+ final: dbusNewFinalSample(3),
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"session bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus", "unix:path=/run/user/1000/bus"}}, nil, nil),
+ call("dbusProxyStart", stub.ExpectArgs{dbusNewFinalSample(3)}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"starting message bus proxy", ignoreValue{}}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"terminating message bus proxy"}}, nil, nil),
+ call("dbusProxyClose", stub.ExpectArgs{dbusNewFinalSample(3)}, nil, nil),
+ call("dbusProxyWait", stub.ExpectArgs{dbusNewFinalSample(3)}, nil, stub.UniqueError(1)),
+ call("verbose", stub.ExpectArgs{[]any{"message bus proxy exit"}}, nil, nil),
+ }, &OpError{
+ Op: "dbus", Err: stub.UniqueError(1), Revert: true,
+ Msg: "message bus proxy error: unique error 1 injected by the test suite",
+ }},
+
+ {"success dbusProxyWait cancel", 0xdead, 0xff, &dbusProxyOp{
+ final: dbusNewFinalSample(2),
+ system: true,
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"session bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus", "unix:path=/run/user/1000/bus"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"system bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket", "unix:path=/run/dbus/system_bus_socket"}}, nil, nil),
+ call("dbusProxyStart", stub.ExpectArgs{dbusNewFinalSample(2)}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"starting message bus proxy", ignoreValue{}}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"terminating message bus proxy"}}, nil, nil),
+ call("dbusProxyClose", stub.ExpectArgs{dbusNewFinalSample(2)}, nil, nil),
+ call("dbusProxyWait", stub.ExpectArgs{dbusNewFinalSample(2)}, nil, context.Canceled),
+ call("verbose", stub.ExpectArgs{[]any{"message bus proxy canceled upstream"}}, nil, nil),
+ }, nil},
+
+ {"success", 0xdead, 0xff, &dbusProxyOp{
+ final: dbusNewFinalSample(1),
+ system: true,
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"session bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus", "unix:path=/run/user/1000/bus"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"system bus proxy on %q for upstream %q", []any{"/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket", "unix:path=/run/dbus/system_bus_socket"}}, nil, nil),
+ call("dbusProxyStart", stub.ExpectArgs{dbusNewFinalSample(1)}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"starting message bus proxy", ignoreValue{}}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"terminating message bus proxy"}}, nil, nil),
+ call("dbusProxyClose", stub.ExpectArgs{dbusNewFinalSample(1)}, nil, nil),
+ call("dbusProxyWait", stub.ExpectArgs{dbusNewFinalSample(1)}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"message bus proxy exit"}}, nil, nil),
+ }, nil},
+ })
+
+ checkOpsBuilder(t, "ProxyDBus", []opsBuilderTestCase{
+ {"nil session", 0xcafe, func(t *testing.T, sys *I) {
+ wantErr := &OpError{
+ Op: "dbus", Err: ErrDBusConfig,
+ Msg: "attempted to create message bus proxy args without session bus config",
+ }
+ if err := sys.ProxyDBus(nil, new(hst.BusConfig), dbus.ProxyPair{}, dbus.ProxyPair{}); !reflect.DeepEqual(err, wantErr) {
+ t.Errorf("ProxyDBus: error = %v, want %v", err, wantErr)
+ }
+ }, nil, stub.Expect{}},
+
+ {"dbusFinalise NUL", 0xcafe, func(_ *testing.T, sys *I) {
+ defer func() {
+ want := "message bus proxy configuration contains NUL byte"
+ if r := recover(); r != want {
+ t.Errorf("MustProxyDBus: panic = %v, want %v", r, want)
+ }
+ }()
+
+ sys.MustProxyDBus(
+ &hst.BusConfig{
+ // use impossible value here as an implicit assert that it goes through the stub
+ Talk: []string{"session\x00"}, Filter: true,
+ }, &hst.BusConfig{
+ // use impossible value here as an implicit assert that it goes through the stub
+ Talk: []string{"system\x00"}, Filter: true,
+ }, dbus.ProxyPair{
+ "unix:path=/run/user/1000/bus",
+ "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus",
+ }, dbus.ProxyPair{
+ "unix:path=/run/dbus/system_bus_socket",
+ "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket",
+ })
+ }, nil, stub.Expect{Calls: []stub.Call{
+ call("dbusFinalise", stub.ExpectArgs{
+ dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus"},
+ dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket"},
+ &hst.BusConfig{Talk: []string{"session\x00"}, Filter: true},
+ &hst.BusConfig{Talk: []string{"system\x00"}, Filter: true},
+ }, (*dbus.Final)(nil), syscall.EINVAL),
+ }}},
+
+ {"dbusFinalise", 0xcafe, func(_ *testing.T, sys *I) {
+ wantErr := &OpError{
+ Op: "dbus", Err: stub.UniqueError(0),
+ Msg: "cannot finalise message bus proxy: unique error 0 injected by the test suite",
+ }
+ if err := sys.ProxyDBus(
+ &hst.BusConfig{
+ // use impossible value here as an implicit assert that it goes through the stub
+ Talk: []string{"session\x00"}, Filter: true,
+ }, &hst.BusConfig{
+ // use impossible value here as an implicit assert that it goes through the stub
+ Talk: []string{"system\x00"}, Filter: true,
+ }, dbus.ProxyPair{
+ "unix:path=/run/user/1000/bus",
+ "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus",
+ }, dbus.ProxyPair{
+ "unix:path=/run/dbus/system_bus_socket",
+ "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket",
+ }); !reflect.DeepEqual(err, wantErr) {
+ t.Errorf("ProxyDBus: error = %v", err)
+ }
+ }, nil, stub.Expect{Calls: []stub.Call{
+ call("dbusFinalise", stub.ExpectArgs{
+ dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus"},
+ dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket"},
+ &hst.BusConfig{Talk: []string{"session\x00"}, Filter: true},
+ &hst.BusConfig{Talk: []string{"system\x00"}, Filter: true},
+ }, (*dbus.Final)(nil), stub.UniqueError(0)),
+ }}},
+
+ {"full", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.MustProxyDBus(
+ &hst.BusConfig{
+ // use impossible value here as an implicit assert that it goes through the stub
+ Talk: []string{"session\x00"}, Filter: true,
+ }, &hst.BusConfig{
+ // use impossible value here as an implicit assert that it goes through the stub
+ Talk: []string{"system\x00"}, Filter: true,
+ }, dbus.ProxyPair{
+ "unix:path=/run/user/1000/bus",
+ "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus",
+ }, dbus.ProxyPair{
+ "unix:path=/run/dbus/system_bus_socket",
+ "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket",
+ })
+ }, []Op{
+ &dbusProxyOp{
+ final: dbusNewFinalSample(0),
+ system: true,
+ },
+ }, stub.Expect{Calls: []stub.Call{
+ call("dbusFinalise", stub.ExpectArgs{
+ dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus"},
+ dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket"},
+ &hst.BusConfig{Talk: []string{"session\x00"}, Filter: true},
+ &hst.BusConfig{Talk: []string{"system\x00"}, Filter: true},
+ }, dbusNewFinalSample(0), nil),
+ call("isVerbose", stub.ExpectArgs{}, true, nil),
+ call("verbose", stub.ExpectArgs{[]any{"session bus proxy:", []string{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus", "--filter", "--talk=session\x00"}}}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"system bus proxy:", []string{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket", "--filter", "--talk=system\x00"}}}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"message bus proxy final args:", helper.MustNewCheckedArgs("unique", "value", "0", "injected", "by", "the", "test", "suite")}}, nil, nil),
+ }}},
+ })
+
+ checkOpIs(t, []opIsTestCase{
+ {"nil", (*dbusProxyOp)(nil), (*dbusProxyOp)(nil), false},
+ {"zero", new(dbusProxyOp), new(dbusProxyOp), false},
+
+ {"system differs", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: false,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, false},
+
+ {"wt differs", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1001/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, false},
+
+ {"final system upstream differs", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket\x00"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, false},
+
+ {"final session upstream differs", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1001/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, false},
+
+ {"final system differs", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.1/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, false},
+
+ {"final session differs", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1001/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, false},
+
+ {"equals", &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, &dbusProxyOp{final: &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"unix", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs(
+ "--filter", "unix:path=/run/user/1000/bus", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/bus",
+ "--filter", "unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/b186c281d9e83a39afdc66d964ef99c6/system_bus_socket",
+ ),
+ }, system: true,
+ }, true},
+ })
+
+ checkOpMeta(t, []opMetaTestCase{
+ {"dbus", new(dbusProxyOp),
+ Process, "/proc/nonexistent",
+ "(invalid dbus proxy)"},
+ })
+}
+
+func dbusNewFinalSample(v int) *dbus.Final {
+ return &dbus.Final{
+ Session: dbus.ProxyPair{"unix:path=/run/user/1000/bus", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/bus"},
+ System: dbus.ProxyPair{"unix:path=/run/dbus/system_bus_socket", "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f/system_bus_socket"},
+
+ SessionUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/user/1000/bus"}}}},
+ SystemUpstream: []dbus.AddrEntry{{Method: "unix", Values: [][2]string{{"path", "/run/dbus/system_bus_socket"}}}},
+
+ WriterTo: helper.MustNewCheckedArgs("unique", "value", strconv.Itoa(v), "injected", "by", "the", "test", "suite"),
+ }
+}
+
+func TestLinePrefixWriter(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ prefix string
+ f func(w func(s string))
+ wantErr []error
+ wantPt []string
+ want []string
+ wantExt []string
+ wantBuf string
+ }{
+ {"nop", "(nop) ", func(func(string)) {}, nil, nil, nil, nil, ""},
+
+ {"partial", "(partial) ", func(w func(string)) {
+ w("C-65533: -> ")
+ }, nil, nil, nil, []string{
+ "*(partial) C-65533: -> ",
+ }, "C-65533: -> "},
+
+ {"break", "(break) ", func(w func(string)) {
+ w("C-65533: -> ")
+ w("org.freedesktop.DBus fake ListNames\n")
+ }, nil, nil, []string{
+ "C-65533: -> org.freedesktop.DBus fake ListNames",
+ }, nil, ""},
+
+ {"break pt", "(break pt) ", func(w func(string)) {
+ w("init: ")
+ w("received setup parameters\n")
+ }, nil, []string{
+ "init: received setup parameters",
+ }, nil, nil, ""},
+
+ {"threshold", "(threshold) ", func(w func(s string)) {
+ w(string(make([]byte, lpwSizeThreshold)))
+ w("\n")
+ }, []error{nil, syscall.ENOMEM}, nil, nil, []string{
+ "*(threshold) " + string(make([]byte, lpwSizeThreshold)),
+ "+(threshold) write threshold reached, output may be incomplete",
+ }, string(make([]byte, lpwSizeThreshold))},
+
+ {"threshold multi", "(threshold multi) ", func(w func(s string)) {
+ w(":3\n")
+ w(string(make([]byte, lpwSizeThreshold-3)))
+ w("\n")
+ }, []error{nil, nil, syscall.ENOMEM}, nil, []string{
+ ":3",
+ }, []string{
+ "*(threshold multi) " + string(make([]byte, lpwSizeThreshold-3)),
+ "+(threshold multi) write threshold reached, output may be incomplete",
+ }, string(make([]byte, lpwSizeThreshold-3))},
+
+ {"threshold multi partial", "(threshold multi partial) ", func(w func(s string)) {
+ w(":3\n")
+ w(string(make([]byte, lpwSizeThreshold-2)))
+ w("dropped\n")
+ }, []error{nil, nil, syscall.ENOMEM}, nil, []string{
+ ":3",
+ }, []string{
+ "*(threshold multi partial) " + string(make([]byte, lpwSizeThreshold-2)),
+ "+(threshold multi partial) write threshold reached, output may be incomplete",
+ }, string(make([]byte, lpwSizeThreshold-2))},
+
+ {"threshold exact", "(threshold exact) ", func(w func(s string)) {
+ w(string(make([]byte, lpwSizeThreshold-1)))
+ w("\n")
+ }, nil, nil, []string{
+ string(make([]byte, lpwSizeThreshold-1)),
+ }, []string{
+ "+(threshold exact) write threshold reached, output may be incomplete",
+ }, ""},
+
+ {"sample", "(dbus) ", func(w func(s string)) {
+ w("init: received setup parameters\n")
+ w(`init: mounting "/nix/store/5gml2l2cj28yvyfyzblzjy1laqpxmyzd-libselinux-3.8.1/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/5gml2l2cj28yvyfyzblzjy1laqpxmyzd-libselinux-3.8.1/lib" on "/sysroot/nix/store/5gml2l2cj28yvyfyzblzjy1laqpxmyzd-libselinux-3.8.1/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/bcs094l67dlbqf7idxxbljp293zms9mh-util-linux-minimal-2.41-lib/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/bcs094l67dlbqf7idxxbljp293zms9mh-util-linux-minimal-2.41-lib/lib" on "/sysroot/nix/store/bcs094l67dlbqf7idxxbljp293zms9mh-util-linux-minimal-2.41-lib/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/jl19fdc7gdxqz9a1s368r9d15vpirnqy-zlib-1.3.1/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/jl19fdc7gdxqz9a1s368r9d15vpirnqy-zlib-1.3.1/lib" on "/sysroot/nix/store/jl19fdc7gdxqz9a1s368r9d15vpirnqy-zlib-1.3.1/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/rnn29mhynsa4ncmk0fkcrdr29n0j20l4-libffi-3.4.8/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/rnn29mhynsa4ncmk0fkcrdr29n0j20l4-libffi-3.4.8/lib" on "/sysroot/nix/store/rnn29mhynsa4ncmk0fkcrdr29n0j20l4-libffi-3.4.8/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/vvp8hlss3d5q6hn0cifq04jrpnp6bini-pcre2-10.44/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/vvp8hlss3d5q6hn0cifq04jrpnp6bini-pcre2-10.44/lib" on "/sysroot/nix/store/vvp8hlss3d5q6hn0cifq04jrpnp6bini-pcre2-10.44/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib" on "/sysroot/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" on "/sysroot/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" flags 0x4005` + "\n")
+ w(`init: mounting "/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib64" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" on "/sysroot/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib64" flags 0x4005` + "\n")
+ w(`init: mounting "/run/user/1000" flags 0x0` + "\n")
+ w(`init: resolved "/host/run/user/1000" on "/sysroot/run/user/1000" flags 0x4005` + "\n")
+ w(`init: mounting "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f" flags 0x2` + "\n")
+ w(`init: resolved "/host/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f" on "/sysroot/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f" flags 0x4004` + "\n")
+ w(`init: mounting "/nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin" flags 0x0` + "\n")
+ w(`init: resolved "/host/nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin" on "/sysroot/nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin" flags 0x4005` + "\n")
+ w("init: resolving presets 0xf\n")
+ w("init: 68 filter rules loaded\n")
+ w("init: starting initial program /nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin/xdg-dbus-proxy\n")
+ w("C1: -> org.freedesktop.DBus call org.freedesktop.DBus.Hello at /org/freedesktop/DBus\n")
+ w("C-65536: -> org.freedesktop.DBus fake wildcarded AddMatch for org.freedesktop.portal\n")
+ w("C-65535: -> org.freedesktop.DBus fake AddMatch for org.freedesktop.Notifications\n")
+ w("C-65534: -> org.freedesktop.DBus fake GetNameOwner for org.freedesktop.Notifications\n")
+ w("C-65533: -> org.freedesktop.DBus fake ListNames\n")
+ w("B1: <- org.freedesktop.DBus return from C1\n")
+ w("B2: <- org.freedesktop.DBus signal org.freedesktop.DBus.NameAcquired at /org/freedesktop/DBus\n")
+ w("B3: <- org.freedesktop.DBus return from C-65536\n")
+ w("*SKIPPED*\n")
+ w("B4: <- org.freedesktop.DBus return from C-65535\n")
+ w("*SKIPPED*\n")
+ w("B5: <- org.freedesktop.DBus return error org.freedesktop.DBus.Error.NameHasNoOwner from C-65534\n")
+ w("*SKIPPED*\n")
+ w("B6: <- org.freedesktop.DBus return from C-65533\n")
+ w("C-65532: -> org.freedesktop.DBus fake GetNameOwner for org.freedesktop.DBus\n")
+ w("*SKIPPED*\n")
+ w("B7: <- org.freedesktop.DBus return from C-65532\n")
+ w("*SKIPPED*\n")
+ w("C2: -> org.freedesktop.DBus call org.freedesktop.DBus.AddMatch at /org/freedesktop/DBus\n")
+ w("C3: -> org.freedesktop.DBus call org.freedesktop.DBus.GetNameOwner at /org/freedesktop/DBus\n")
+ w("C4: -> org.freedesktop.DBus call org.freedesktop.DBus.AddMatch at /org/freedesktop/DBus\n")
+ w("C5: -> org.freedesktop.DBus call org.freedesktop.DBus.StartServiceByName at /org/freedesktop/DBus\n")
+ w("B8: <- org.freedesktop.DBus return from C2\n")
+ w("B9: <- org.freedesktop.DBus return error org.freedesktop.DBus.Error.NameHasNoOwner from C3\n")
+ w("B10: <- org.freedesktop.DBus return from C4\n")
+ w("B12: <- org.freedesktop.DBus signal org.freedesktop.DBus.NameOwnerChanged at /org/freedesktop/DBus\n")
+ w("B11: <- org.freedesktop.DBus return from C5\n")
+ w("C6: -> org.freedesktop.DBus call org.freedesktop.DBus.GetNameOwner at /org/freedesktop/DBus\n")
+ w("B13: <- org.freedesktop.DBus return from C6\n")
+ w("C7: -> :1.4 call org.freedesktop.Notifications.GetServerInformation at /org/freedesktop/Notifications\n")
+ w("B4: <- :1.4 return from C7\n")
+ w("C8: -> :1.4 call org.freedesktop.Notifications.GetServerInformation at /org/freedesktop/Notifications\n")
+ w("B5: <- :1.4 return from C8\n")
+ w("C9: -> :1.4 call org.freedesktop.Notifications.Notify at /org/freedesktop/Notifications\n")
+ w("B6: <- :1.4 return from C9\n")
+ w("C10: -> org.freedesktop.DBus call org.freedesktop.DBus.RemoveMatch at /org/freedesktop/DBus\n")
+ w("C11: -> org.freedesktop.DBus call org.freedesktop.DBus.RemoveMatch at /org/freedesktop/DBus\n")
+ w("B14: <- org.freedesktop.DBus return from C10\n")
+ w("B15: <- org.freedesktop.DBus return from C11\n")
+ w("init: initial process exited with code 0\n")
+ }, nil, []string{
+ "init: received setup parameters",
+ `init: mounting "/nix/store/5gml2l2cj28yvyfyzblzjy1laqpxmyzd-libselinux-3.8.1/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/5gml2l2cj28yvyfyzblzjy1laqpxmyzd-libselinux-3.8.1/lib" on "/sysroot/nix/store/5gml2l2cj28yvyfyzblzjy1laqpxmyzd-libselinux-3.8.1/lib" flags 0x4005`,
+ `init: mounting "/nix/store/bcs094l67dlbqf7idxxbljp293zms9mh-util-linux-minimal-2.41-lib/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/bcs094l67dlbqf7idxxbljp293zms9mh-util-linux-minimal-2.41-lib/lib" on "/sysroot/nix/store/bcs094l67dlbqf7idxxbljp293zms9mh-util-linux-minimal-2.41-lib/lib" flags 0x4005`,
+ `init: mounting "/nix/store/jl19fdc7gdxqz9a1s368r9d15vpirnqy-zlib-1.3.1/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/jl19fdc7gdxqz9a1s368r9d15vpirnqy-zlib-1.3.1/lib" on "/sysroot/nix/store/jl19fdc7gdxqz9a1s368r9d15vpirnqy-zlib-1.3.1/lib" flags 0x4005`,
+ `init: mounting "/nix/store/rnn29mhynsa4ncmk0fkcrdr29n0j20l4-libffi-3.4.8/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/rnn29mhynsa4ncmk0fkcrdr29n0j20l4-libffi-3.4.8/lib" on "/sysroot/nix/store/rnn29mhynsa4ncmk0fkcrdr29n0j20l4-libffi-3.4.8/lib" flags 0x4005`,
+ `init: mounting "/nix/store/vvp8hlss3d5q6hn0cifq04jrpnp6bini-pcre2-10.44/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/vvp8hlss3d5q6hn0cifq04jrpnp6bini-pcre2-10.44/lib" on "/sysroot/nix/store/vvp8hlss3d5q6hn0cifq04jrpnp6bini-pcre2-10.44/lib" flags 0x4005`,
+ `init: mounting "/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib" on "/sysroot/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib" flags 0x4005`,
+ `init: mounting "/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" flags 0x0`,
+ `init: resolved "/host/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" on "/sysroot/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" flags 0x4005`,
+ `init: mounting "/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib64" flags 0x0`,
+ `init: resolved "/host/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib" on "/sysroot/nix/store/zdpby3l6azi78sl83cpad2qjpfj25aqx-glibc-2.40-66/lib64" flags 0x4005`,
+ `init: mounting "/run/user/1000" flags 0x0`,
+ `init: resolved "/host/run/user/1000" on "/sysroot/run/user/1000" flags 0x4005`,
+ `init: mounting "/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f" flags 0x2`,
+ `init: resolved "/host/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f" on "/sysroot/tmp/hakurei.0/99dd71ee2146369514e0d10783368f8f" flags 0x4004`,
+ `init: mounting "/nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin" flags 0x0`,
+ `init: resolved "/host/nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin" on "/sysroot/nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin" flags 0x4005`,
+ "init: resolving presets 0xf",
+ "init: 68 filter rules loaded",
+ "init: starting initial program /nix/store/d2divmq2d897amikcwpdx7zrbpddxxcl-xdg-dbus-proxy-0.1.6/bin/xdg-dbus-proxy",
+
+ "init: initial process exited with code 0",
+ }, []string{
+ "C1: -> org.freedesktop.DBus call org.freedesktop.DBus.Hello at /org/freedesktop/DBus",
+ "C-65536: -> org.freedesktop.DBus fake wildcarded AddMatch for org.freedesktop.portal",
+ "C-65535: -> org.freedesktop.DBus fake AddMatch for org.freedesktop.Notifications",
+ "C-65534: -> org.freedesktop.DBus fake GetNameOwner for org.freedesktop.Notifications",
+ "C-65533: -> org.freedesktop.DBus fake ListNames",
+ "B1: <- org.freedesktop.DBus return from C1",
+ "B2: <- org.freedesktop.DBus signal org.freedesktop.DBus.NameAcquired at /org/freedesktop/DBus",
+ "B3: <- org.freedesktop.DBus return from C-65536",
+ "*SKIPPED*",
+ "B4: <- org.freedesktop.DBus return from C-65535",
+ "*SKIPPED*",
+ "B5: <- org.freedesktop.DBus return error org.freedesktop.DBus.Error.NameHasNoOwner from C-65534",
+ "*SKIPPED*",
+ "B6: <- org.freedesktop.DBus return from C-65533",
+ "C-65532: -> org.freedesktop.DBus fake GetNameOwner for org.freedesktop.DBus",
+ "*SKIPPED*",
+ "B7: <- org.freedesktop.DBus return from C-65532",
+ "*SKIPPED*",
+ "C2: -> org.freedesktop.DBus call org.freedesktop.DBus.AddMatch at /org/freedesktop/DBus",
+ "C3: -> org.freedesktop.DBus call org.freedesktop.DBus.GetNameOwner at /org/freedesktop/DBus",
+ "C4: -> org.freedesktop.DBus call org.freedesktop.DBus.AddMatch at /org/freedesktop/DBus",
+ "C5: -> org.freedesktop.DBus call org.freedesktop.DBus.StartServiceByName at /org/freedesktop/DBus",
+ "B8: <- org.freedesktop.DBus return from C2",
+ "B9: <- org.freedesktop.DBus return error org.freedesktop.DBus.Error.NameHasNoOwner from C3",
+ "B10: <- org.freedesktop.DBus return from C4",
+ "B12: <- org.freedesktop.DBus signal org.freedesktop.DBus.NameOwnerChanged at /org/freedesktop/DBus",
+ "B11: <- org.freedesktop.DBus return from C5",
+ "C6: -> org.freedesktop.DBus call org.freedesktop.DBus.GetNameOwner at /org/freedesktop/DBus",
+ "B13: <- org.freedesktop.DBus return from C6",
+ "C7: -> :1.4 call org.freedesktop.Notifications.GetServerInformation at /org/freedesktop/Notifications",
+ "B4: <- :1.4 return from C7",
+ "C8: -> :1.4 call org.freedesktop.Notifications.GetServerInformation at /org/freedesktop/Notifications",
+ "B5: <- :1.4 return from C8",
+ "C9: -> :1.4 call org.freedesktop.Notifications.Notify at /org/freedesktop/Notifications",
+ "B6: <- :1.4 return from C9",
+ "C10: -> org.freedesktop.DBus call org.freedesktop.DBus.RemoveMatch at /org/freedesktop/DBus",
+ "C11: -> org.freedesktop.DBus call org.freedesktop.DBus.RemoveMatch at /org/freedesktop/DBus",
+ "B14: <- org.freedesktop.DBus return from C10",
+ "B15: <- org.freedesktop.DBus return from C11",
+ }, nil, ""},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ gotPt := make([]string, 0, len(tc.wantPt))
+ out := &linePrefixWriter{
+ prefix: tc.prefix,
+ println: func(v ...any) {
+ if len(v) != 1 {
+ t.Fatalf("invalid call to println: %#v", v)
+ }
+ gotPt = append(gotPt, v[0].(string))
+ },
+ buf: new(strings.Builder),
+ }
+
+ var pos int
+ tc.f(func(s string) {
+ _, err := out.Write([]byte(s))
+ if tc.wantErr != nil {
+ if !reflect.DeepEqual(err, tc.wantErr[pos]) {
+ t.Fatalf("Write: error = %v, want %v", err, tc.wantErr[pos])
+ }
+ } else if err != nil {
+ t.Fatalf("Write: unexpected error: %v", err)
+ return
+ }
+ pos++
+ })
+
+ if !slices.Equal(out.msg, tc.want) {
+ t.Errorf("msg: %#v, want %#v", out.msg, tc.want)
+ }
+
+ if out.buf.String() != tc.wantBuf {
+ t.Errorf("buf: %q, want %q", out.buf, tc.wantBuf)
+ }
+
+ wantPt := make([]string, len(tc.wantPt))
+ for i, m := range tc.wantPt {
+ wantPt[i] = tc.prefix + m
+ }
+ if !slices.Equal(gotPt, wantPt) {
+ t.Errorf("passthrough: %#v, want %#v", gotPt, wantPt)
+ }
+
+ wantDump := make([]string, len(tc.want)+len(tc.wantExt))
+ for i, want := range tc.want {
+ wantDump[i] = tc.prefix + want
+ }
+ for i, want := range tc.wantExt {
+ wantDump[len(tc.want)+i] = want
+ }
+ t.Run("dump", func(t *testing.T) {
+ got := make([]string, 0, len(wantDump))
+ out.println = func(v ...any) {
+ if len(v) != 1 {
+ t.Fatalf("Dump: invalid call to println: %#v", v)
+ }
+ got = append(got, v[0].(string))
+ }
+ out.Dump()
+
+ if !slices.Equal(got, wantDump) {
+ t.Errorf("Dump: %#v, want %#v", got, wantDump)
+ }
+ })
+ })
+ }
+}
diff --git a/internal/system/dispatcher.go b/internal/system/dispatcher.go
new file mode 100644
index 00000000..86aebcaa
--- /dev/null
+++ b/internal/system/dispatcher.go
@@ -0,0 +1,89 @@
+package system
+
+import (
+ "io"
+ "io/fs"
+ "log"
+ "os"
+
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/acl"
+ "hakurei.app/internal/system/dbus"
+ "hakurei.app/internal/system/xcb"
+)
+
+type osFile interface {
+ Name() string
+ io.Writer
+ fs.File
+}
+
+// syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour.
+// syscallDispatcher is embedded in [I], so all methods must be unexported.
+type syscallDispatcher interface {
+ // new starts a goroutine with a new instance of syscallDispatcher.
+ // A syscallDispatcher must never be used in any goroutine other than the one owning it,
+ // just synchronising access is not enough, as this is for test instrumentation.
+ new(f func(k syscallDispatcher))
+
+ // stat provides os.Stat.
+ stat(name string) (os.FileInfo, error)
+ // open provides [os.Open].
+ open(name string) (osFile, error)
+ // mkdir provides os.Mkdir.
+ mkdir(name string, perm os.FileMode) error
+ // chmod provides os.Chmod.
+ chmod(name string, mode os.FileMode) error
+ // link provides os.Link.
+ link(oldname, newname string) error
+ // remove provides os.Remove.
+ remove(name string) error
+
+ // println provides [log.Println].
+ println(v ...any)
+
+ // aclUpdate provides [acl.Update].
+ aclUpdate(name string, uid int, perms ...acl.Perm) error
+
+ // xcbChangeHosts provides [xcb.ChangeHosts].
+ xcbChangeHosts(mode xcb.HostMode, family xcb.Family, address string) error
+
+ // dbusFinalise provides [dbus.Finalise].
+ dbusFinalise(sessionBus, systemBus dbus.ProxyPair, session, system *hst.BusConfig) (final *dbus.Final, err error)
+ // dbusProxyStart provides the Start method of [dbus.Proxy].
+ dbusProxyStart(proxy *dbus.Proxy) error
+ // dbusProxyClose provides the Close method of [dbus.Proxy].
+ dbusProxyClose(proxy *dbus.Proxy)
+ // dbusProxyWait provides the Wait method of [dbus.Proxy].
+ dbusProxyWait(proxy *dbus.Proxy) error
+}
+
+// direct implements syscallDispatcher on the current kernel.
+type direct struct{}
+
+func (k direct) new(f func(k syscallDispatcher)) { go f(k) }
+
+func (k direct) stat(name string) (os.FileInfo, error) { return os.Stat(name) }
+func (k direct) open(name string) (osFile, error) { return os.Open(name) }
+func (k direct) mkdir(name string, perm os.FileMode) error { return os.Mkdir(name, perm) }
+func (k direct) chmod(name string, mode os.FileMode) error { return os.Chmod(name, mode) }
+func (k direct) link(oldname, newname string) error { return os.Link(oldname, newname) }
+func (k direct) remove(name string) error { return os.Remove(name) }
+
+func (k direct) println(v ...any) { log.Println(v...) }
+
+func (k direct) aclUpdate(name string, uid int, perms ...acl.Perm) error {
+ return acl.Update(name, uid, perms...)
+}
+
+func (k direct) xcbChangeHosts(mode xcb.HostMode, family xcb.Family, address string) error {
+ return xcb.ChangeHosts(mode, family, address)
+}
+
+func (k direct) dbusFinalise(sessionBus, systemBus dbus.ProxyPair, session, system *hst.BusConfig) (final *dbus.Final, err error) {
+ return dbus.Finalise(sessionBus, systemBus, session, system)
+}
+
+func (k direct) dbusProxyStart(proxy *dbus.Proxy) error { return proxy.Start() }
+func (k direct) dbusProxyClose(proxy *dbus.Proxy) { proxy.Close() }
+func (k direct) dbusProxyWait(proxy *dbus.Proxy) error { return proxy.Wait() }
diff --git a/internal/system/dispatcher_test.go b/internal/system/dispatcher_test.go
new file mode 100644
index 00000000..8000f0c7
--- /dev/null
+++ b/internal/system/dispatcher_test.go
@@ -0,0 +1,379 @@
+package system
+
+import (
+ "log"
+ "os"
+ "reflect"
+ "slices"
+ "testing"
+ "unsafe"
+
+ "hakurei.app/container/stub"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/acl"
+ "hakurei.app/internal/system/dbus"
+ "hakurei.app/internal/system/xcb"
+)
+
+// call initialises a [stub.Call].
+// This keeps composites analysis happy without making the test cases too bloated.
+func call(name string, args stub.ExpectArgs, ret any, err error) stub.Call {
+ return stub.Call{Name: name, Args: args, Ret: ret, Err: err}
+}
+
+type opBehaviourTestCase struct {
+ name string
+ uid int
+ ec hst.Enablement
+ op Op
+
+ apply []stub.Call
+ wantErrApply error
+
+ revert []stub.Call
+ wantErrRevert error
+}
+
+func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) {
+ t.Helper()
+
+ t.Run("behaviour", func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ var ec *Criteria
+ if tc.ec != 0xff {
+ ec = (*Criteria)(&tc.ec)
+ }
+
+ sys, s := InternalNew(t, stub.Expect{Calls: slices.Concat(tc.apply, []stub.Call{{Name: stub.CallSeparator}}, tc.revert)}, tc.uid)
+ defer stub.HandleExit(t)
+ errApply := tc.op.apply(sys)
+ s.Expects(stub.CallSeparator)
+ if !reflect.DeepEqual(errApply, tc.wantErrApply) {
+ t.Errorf("apply: error = %v, want %v", errApply, tc.wantErrApply)
+ }
+ if errApply != nil {
+ goto out
+ }
+
+ if err := tc.op.revert(sys, ec); !reflect.DeepEqual(err, tc.wantErrRevert) {
+ t.Errorf("revert: error = %v, want %v", err, tc.wantErrRevert)
+ }
+
+ out:
+ s.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) {
+ count := s.Pos() - 1 // separator
+ if count < len(tc.apply) {
+ t.Errorf("apply: %d calls, want %d", count, len(tc.apply))
+ } else {
+ t.Errorf("revert: %d calls, want %d", count-len(tc.apply), len(tc.revert))
+ }
+ })
+ })
+ }
+ })
+}
+
+type opsBuilderTestCase struct {
+ name string
+ uid int
+ f func(t *testing.T, sys *I)
+ want []Op
+ exp stub.Expect
+}
+
+func checkOpsBuilder(t *testing.T, fname string, testCases []opsBuilderTestCase) {
+ t.Helper()
+
+ t.Run("build", func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ sys, s := InternalNew(t, tc.exp, tc.uid)
+ defer stub.HandleExit(t)
+ tc.f(t, sys)
+ s.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) {
+ t.Helper()
+
+ t.Errorf("%s: %d calls, want %d", fname, s.Pos(), s.Len())
+ })
+ if !slices.EqualFunc(sys.ops, tc.want, func(op Op, v Op) bool { return op.Is(v) }) {
+ t.Errorf("ops: %#v, want %#v", sys.ops, tc.want)
+ }
+ })
+ }
+ })
+}
+
+type opIsTestCase struct {
+ name string
+ op, v Op
+ want bool
+}
+
+func checkOpIs(t *testing.T, testCases []opIsTestCase) {
+ t.Helper()
+
+ t.Run("is", func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ if got := tc.op.Is(tc.v); got != tc.want {
+ t.Errorf("Is: %v, want %v", got, tc.want)
+ }
+ })
+ }
+ })
+}
+
+type opMetaTestCase struct {
+ name string
+ op Op
+
+ wantType hst.Enablement
+ wantPath string
+ wantString string
+}
+
+func checkOpMeta(t *testing.T, testCases []opMetaTestCase) {
+ t.Helper()
+
+ t.Run("meta", func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Helper()
+ t.Parallel()
+
+ t.Run("type", func(t *testing.T) {
+ t.Helper()
+
+ if got := tc.op.Type(); got != tc.wantType {
+ t.Errorf("Type: %q, want %q", got, tc.wantType)
+ }
+ })
+
+ t.Run("path", func(t *testing.T) {
+ t.Helper()
+
+ if got := tc.op.Path(); got != tc.wantPath {
+ t.Errorf("Path: %q, want %q", got, tc.wantPath)
+ }
+ })
+
+ t.Run("string", func(t *testing.T) {
+ t.Helper()
+
+ if got := tc.op.String(); got != tc.wantString {
+ t.Errorf("String: %s, want %s", got, tc.wantString)
+ }
+ })
+ })
+ }
+ })
+}
+
+// InternalNew initialises [I] with a stub syscallDispatcher.
+func InternalNew(t *testing.T, want stub.Expect, uid int) (*I, *stub.Stub[syscallDispatcher]) {
+ k := &kstub{stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{s} }, want)}
+ sys := New(t.Context(), k, uid)
+ sys.syscallDispatcher = k
+ return sys, k.Stub
+}
+
+type kstub struct{ *stub.Stub[syscallDispatcher] }
+
+func (k *kstub) new(f func(k syscallDispatcher)) { k.Helper(); k.New(f) }
+
+func (k *kstub) stat(name string) (fi os.FileInfo, err error) {
+ k.Helper()
+ expect := k.Expects("stat")
+ err = expect.Error(
+ stub.CheckArg(k.Stub, "name", name, 0))
+ if err == nil {
+ fi = expect.Ret.(os.FileInfo)
+ }
+ return
+}
+
+func (k *kstub) open(name string) (f osFile, err error) {
+ k.Helper()
+ expect := k.Expects("open")
+ err = expect.Error(
+ stub.CheckArg(k.Stub, "name", name, 0))
+ if err == nil {
+ f = expect.Ret.(osFile)
+ }
+ return
+}
+
+func (k *kstub) mkdir(name string, perm os.FileMode) error {
+ k.Helper()
+ return k.Expects("mkdir").Error(
+ stub.CheckArg(k.Stub, "name", name, 0),
+ stub.CheckArg(k.Stub, "perm", perm, 1))
+}
+
+func (k *kstub) chmod(name string, mode os.FileMode) error {
+ k.Helper()
+ return k.Expects("chmod").Error(
+ stub.CheckArg(k.Stub, "name", name, 0),
+ stub.CheckArg(k.Stub, "mode", mode, 1))
+}
+
+func (k *kstub) link(oldname, newname string) error {
+ k.Helper()
+ return k.Expects("link").Error(
+ stub.CheckArg(k.Stub, "oldname", oldname, 0),
+ stub.CheckArg(k.Stub, "newname", newname, 1))
+}
+
+func (k *kstub) remove(name string) error {
+ k.Helper()
+ return k.Expects("remove").Error(
+ stub.CheckArg(k.Stub, "name", name, 0))
+}
+
+func (k *kstub) println(v ...any) {
+ k.Helper()
+ k.Expects("println")
+ if !stub.CheckArgReflect(k.Stub, "v", v, 0) {
+ k.FailNow()
+ }
+}
+
+func (k *kstub) aclUpdate(name string, uid int, perms ...acl.Perm) error {
+ k.Helper()
+ return k.Expects("aclUpdate").Error(
+ stub.CheckArg(k.Stub, "name", name, 0),
+ stub.CheckArg(k.Stub, "uid", uid, 1),
+ stub.CheckArgReflect(k.Stub, "perms", perms, 2))
+}
+
+func (k *kstub) xcbChangeHosts(mode xcb.HostMode, family xcb.Family, address string) error {
+ k.Helper()
+ return k.Expects("xcbChangeHosts").Error(
+ stub.CheckArg(k.Stub, "mode", mode, 0),
+ stub.CheckArg(k.Stub, "family", family, 1),
+ stub.CheckArg(k.Stub, "address", address, 2))
+}
+
+func (k *kstub) dbusFinalise(sessionBus, systemBus dbus.ProxyPair, session, system *hst.BusConfig) (final *dbus.Final, err error) {
+ k.Helper()
+ expect := k.Expects("dbusFinalise")
+
+ final = expect.Ret.(*dbus.Final)
+ err = expect.Error(
+ stub.CheckArg(k.Stub, "sessionBus", sessionBus, 0),
+ stub.CheckArg(k.Stub, "systemBus", systemBus, 1),
+ stub.CheckArgReflect(k.Stub, "session", session, 2),
+ stub.CheckArgReflect(k.Stub, "system", system, 3))
+ if err != nil {
+ final = nil
+ }
+ return
+}
+
+func (k *kstub) dbusProxyStart(proxy *dbus.Proxy) error {
+ k.Helper()
+ return k.dbusProxySCW(k.Expects("dbusProxyStart"), proxy)
+}
+func (k *kstub) dbusProxyClose(proxy *dbus.Proxy) {
+ k.Helper()
+ if k.dbusProxySCW(k.Expects("dbusProxyClose"), proxy) != nil {
+ k.Fail()
+ }
+}
+func (k *kstub) dbusProxyWait(proxy *dbus.Proxy) error {
+ k.Helper()
+ return k.dbusProxySCW(k.Expects("dbusProxyWait"), proxy)
+}
+func (k *kstub) dbusProxySCW(expect *stub.Call, proxy *dbus.Proxy) error {
+ k.Helper()
+ v := reflect.ValueOf(proxy).Elem()
+
+ if ctxV := v.FieldByName("ctx"); ctxV.IsNil() {
+ k.Errorf("proxy: ctx = %s", ctxV.String())
+ return os.ErrInvalid
+ }
+
+ finalV := v.FieldByName("final")
+ if gotFinal := reflect.NewAt(finalV.Type(), unsafe.Pointer(finalV.UnsafeAddr())).Elem().Interface().(*dbus.Final); !reflect.DeepEqual(gotFinal, expect.Args[0]) {
+ k.Errorf("proxy: final = %#v, want %#v", gotFinal, expect.Args[0])
+ return os.ErrInvalid
+ }
+
+ outputV := v.FieldByName("output")
+ if _, ok := reflect.NewAt(outputV.Type(), unsafe.Pointer(outputV.UnsafeAddr())).Elem().Interface().(*linePrefixWriter); !ok {
+ k.Errorf("proxy: output = %s", outputV.String())
+ return os.ErrInvalid
+ }
+
+ return expect.Err
+}
+
+func (k *kstub) GetLogger() *log.Logger { panic("unreachable") }
+
+func (k *kstub) IsVerbose() bool { k.Helper(); return k.Expects("isVerbose").Ret.(bool) }
+func (k *kstub) SwapVerbose(verbose bool) bool {
+ k.Helper()
+ expect := k.Expects("swapVerbose")
+ if expect.Error(
+ stub.CheckArg(k.Stub, "verbose", verbose, 0)) != nil {
+ k.FailNow()
+ }
+ return expect.Ret.(bool)
+}
+
+// ignoreValue marks a value to be ignored by the test suite.
+type ignoreValue struct{}
+
+func (k *kstub) Verbose(v ...any) {
+ k.Helper()
+ expect := k.Expects("verbose")
+
+ // translate ignores in v
+ if want, ok := expect.Args[0].([]any); ok && len(v) == len(want) {
+ for i, a := range want {
+ if _, ok = a.(ignoreValue); ok {
+ v[i] = ignoreValue{}
+ }
+ }
+ }
+
+ if expect.Error(
+ stub.CheckArgReflect(k.Stub, "v", v, 0)) != nil {
+ k.FailNow()
+ }
+}
+
+func (k *kstub) Verbosef(format string, v ...any) {
+ k.Helper()
+ if k.Expects("verbosef").Error(
+ stub.CheckArg(k.Stub, "format", format, 0),
+ stub.CheckArgReflect(k.Stub, "v", v, 1)) != nil {
+ k.FailNow()
+ }
+}
+
+func (k *kstub) Suspend() bool { k.Helper(); return k.Expects("suspend").Ret.(bool) }
+func (k *kstub) Resume() bool { k.Helper(); return k.Expects("resume").Ret.(bool) }
+func (k *kstub) BeforeExit() { k.Helper(); k.Expects("beforeExit") }
diff --git a/internal/system/link.go b/internal/system/link.go
new file mode 100644
index 00000000..a13adaae
--- /dev/null
+++ b/internal/system/link.go
@@ -0,0 +1,50 @@
+package system
+
+import (
+ "fmt"
+
+ "hakurei.app/container/check"
+ "hakurei.app/hst"
+)
+
+// Link calls LinkFileType with the [Process] criteria.
+func (sys *I) Link(oldname, newname *check.Absolute) *I {
+ return sys.LinkFileType(Process, oldname, newname)
+}
+
+// LinkFileType maintains a hardlink until its [Enablement] is no longer satisfied.
+func (sys *I) LinkFileType(et hst.Enablement, oldname, newname *check.Absolute) *I {
+ sys.ops = append(sys.ops, &hardlinkOp{et, newname.String(), oldname.String()})
+ return sys
+}
+
+// hardlinkOp implements [I.LinkFileType].
+type hardlinkOp struct {
+ et hst.Enablement
+ dst, src string
+}
+
+func (l *hardlinkOp) Type() hst.Enablement { return l.et }
+
+func (l *hardlinkOp) apply(sys *I) error {
+ sys.msg.Verbose("linking", l)
+ return newOpError("hardlink", sys.link(l.src, l.dst), false)
+}
+
+func (l *hardlinkOp) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(l.Type()) {
+ sys.msg.Verbosef("removing hard link %q", l.dst)
+ return newOpError("hardlink", sys.remove(l.dst), true)
+ } else {
+ sys.msg.Verbosef("skipping hard link %q", l.dst)
+ return nil
+ }
+}
+
+func (l *hardlinkOp) Is(o Op) bool {
+ target, ok := o.(*hardlinkOp)
+ return ok && l != nil && target != nil && *l == *target
+}
+
+func (l *hardlinkOp) Path() string { return l.src }
+func (l *hardlinkOp) String() string { return fmt.Sprintf("%q from %q", l.dst, l.src) }
diff --git a/internal/system/link_test.go b/internal/system/link_test.go
new file mode 100644
index 00000000..d719931f
--- /dev/null
+++ b/internal/system/link_test.go
@@ -0,0 +1,87 @@
+package system
+
+import (
+ "testing"
+
+ "hakurei.app/container/stub"
+ "hakurei.app/hst"
+)
+
+func TestHardlinkOp(t *testing.T) {
+ t.Parallel()
+
+ checkOpBehaviour(t, []opBehaviourTestCase{
+ {"link", 0xbeef, 0xff, &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"linking", &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}}}, nil, nil),
+ call("link", stub.ExpectArgs{"/run/user/1000/pulse/native", "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}, nil, stub.UniqueError(1)),
+ }, &OpError{Op: "hardlink", Err: stub.UniqueError(1)}, nil, nil},
+
+ {"remove", 0xbeef, 0xff, &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"linking", &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}}}, nil, nil),
+ call("link", stub.ExpectArgs{"/run/user/1000/pulse/native", "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"removing hard link %q", []any{"/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}, nil, stub.UniqueError(0)),
+ }, &OpError{Op: "hardlink", Err: stub.UniqueError(0), Revert: true}},
+
+ {"success skip", 0xbeef, hst.EWayland | hst.EX11, &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"linking", &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}}}, nil, nil),
+ call("link", stub.ExpectArgs{"/run/user/1000/pulse/native", "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"skipping hard link %q", []any{"/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}}, nil, nil),
+ }, nil},
+
+ {"success", 0xbeef, 0xff, &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"linking", &hardlinkOp{hst.EPulse, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"}}}, nil, nil),
+ call("link", stub.ExpectArgs{"/run/user/1000/pulse/native", "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"removing hard link %q", []any{"/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"}, nil, nil),
+ }, nil},
+ })
+
+ checkOpsBuilder(t, "LinkFileType", []opsBuilderTestCase{
+ {"type", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.LinkFileType(User, m("/run/user/1000/pulse/native"), m("/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"))
+ }, []Op{
+ &hardlinkOp{User, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ }, stub.Expect{}},
+
+ {"link", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.Link(m("/run/user/1000/pulse/native"), m("/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse"))
+ }, []Op{
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ }, stub.Expect{}},
+ })
+
+ checkOpIs(t, []opIsTestCase{
+ {"nil", (*hardlinkOp)(nil), (*hardlinkOp)(nil), false},
+ {"zero", new(hardlinkOp), new(hardlinkOp), true},
+
+ {"src differs",
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse"},
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ false},
+
+ {"dst differs",
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6", "/run/user/1000/pulse/native"},
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ false},
+
+ {"et differs",
+ &hardlinkOp{User, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ false},
+
+ {"equals",
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ true},
+ })
+
+ checkOpMeta(t, []opMetaTestCase{
+ {"link", &hardlinkOp{Process, "/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse", "/run/user/1000/pulse/native"},
+ Process, "/run/user/1000/pulse/native",
+ `"/run/user/1000/hakurei/9663730666a44cfc2a81610379e02ed6/pulse" from "/run/user/1000/pulse/native"`},
+ })
+}
diff --git a/internal/system/mkdir.go b/internal/system/mkdir.go
new file mode 100644
index 00000000..f375579c
--- /dev/null
+++ b/internal/system/mkdir.go
@@ -0,0 +1,76 @@
+package system
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "hakurei.app/container/check"
+ "hakurei.app/hst"
+)
+
+// Ensure ensures the existence of a directory.
+func (sys *I) Ensure(name *check.Absolute, perm os.FileMode) *I {
+ sys.ops = append(sys.ops, &mkdirOp{User, name.String(), perm, false})
+ return sys
+}
+
+// Ephemeral ensures the existence of a directory until its [Enablement] is no longer satisfied.
+func (sys *I) Ephemeral(et hst.Enablement, name *check.Absolute, perm os.FileMode) *I {
+ sys.ops = append(sys.ops, &mkdirOp{et, name.String(), perm, true})
+ return sys
+}
+
+// mkdirOp implements [I.Ensure] and [I.Ephemeral].
+type mkdirOp struct {
+ et hst.Enablement
+ path string
+ perm os.FileMode
+ ephemeral bool
+}
+
+func (m *mkdirOp) Type() hst.Enablement { return m.et }
+
+func (m *mkdirOp) apply(sys *I) error {
+ sys.msg.Verbose("ensuring directory", m)
+
+ if err := sys.mkdir(m.path, m.perm); err != nil {
+ if !errors.Is(err, os.ErrExist) {
+ return newOpError("mkdir", err, false)
+ }
+ // directory exists, ensure mode
+ return newOpError("mkdir", sys.chmod(m.path, m.perm), false)
+ } else {
+ return nil
+ }
+}
+
+func (m *mkdirOp) revert(sys *I, ec *Criteria) error {
+ if !m.ephemeral {
+ // skip non-ephemeral dir and do not log anything
+ return nil
+ }
+
+ if ec.hasType(m.Type()) {
+ sys.msg.Verbose("destroying ephemeral directory", m)
+ return newOpError("mkdir", sys.remove(m.path), true)
+ } else {
+ sys.msg.Verbose("skipping ephemeral directory", m)
+ return nil
+ }
+}
+
+func (m *mkdirOp) Is(o Op) bool {
+ target, ok := o.(*mkdirOp)
+ return ok && m != nil && target != nil && *m == *target
+}
+
+func (m *mkdirOp) Path() string { return m.path }
+
+func (m *mkdirOp) 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
new file mode 100644
index 00000000..e8ffde2a
--- /dev/null
+++ b/internal/system/mkdir_test.go
@@ -0,0 +1,113 @@
+package system
+
+import (
+ "os"
+ "testing"
+
+ "hakurei.app/container/stub"
+)
+
+func TestMkdirOp(t *testing.T) {
+ t.Parallel()
+
+ checkOpBehaviour(t, []opBehaviourTestCase{
+ {"mkdir", 0xbeef, 0xff, &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, stub.UniqueError(2)),
+ }, &OpError{Op: "mkdir", Err: stub.UniqueError(2)}, nil, nil},
+
+ {"chmod", 0xbeef, 0xff, &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, os.ErrExist),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, stub.UniqueError(1)),
+ }, &OpError{Op: "mkdir", Err: stub.UniqueError(1)}, nil, nil},
+
+ {"remove", 0xbeef, 0xff, &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"destroying ephemeral directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"}, nil, stub.UniqueError(0)),
+ }, &OpError{Op: "mkdir", Err: stub.UniqueError(0), Revert: true}},
+
+ {"success exist chmod", 0xbeef, 0xff, &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, os.ErrExist),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ }, nil, nil, nil},
+
+ {"success ensure", 0xbeef, 0xff, &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, false}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ }, nil, nil, nil},
+
+ {"success skip", 0xbeef, 0xff, &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"skipping ephemeral directory", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ }, nil},
+
+ {"success", 0xbeef, 0xff, &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"destroying ephemeral directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"}, nil, nil),
+ }, nil},
+ })
+
+ checkOpsBuilder(t, "EnsureEphemeral", []opsBuilderTestCase{
+ {"ensure", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.Ensure(m("/tmp/hakurei.0"), 0700)
+ }, []Op{
+ &mkdirOp{User, "/tmp/hakurei.0", 0700, false},
+ }, stub.Expect{}},
+
+ {"ephemeral", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.Ephemeral(Process, m("/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"), 0711)
+ }, []Op{
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true},
+ }, stub.Expect{}},
+ })
+
+ checkOpIs(t, []opIsTestCase{
+ {"nil", (*mkdirOp)(nil), (*mkdirOp)(nil), false},
+ {"zero", new(mkdirOp), new(mkdirOp), true},
+
+ {"ephemeral differs",
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, false},
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ false},
+
+ {"perm differs",
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0701, true},
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ false},
+
+ {"path differs",
+ &mkdirOp{Process, "/tmp/hakurei.1/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ false},
+
+ {"et differs",
+ &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ false},
+
+ {"equals",
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ true},
+ })
+
+ checkOpMeta(t, []opMetaTestCase{
+ {"ensure", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, false},
+ User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9",
+ `mode: -rwx------ type: ensure path: "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"`},
+
+ {"ephemeral", &mkdirOp{User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0700, true},
+ User, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9",
+ `mode: -rwx------ type: user path: "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"`},
+ })
+}
diff --git a/internal/system/output.go b/internal/system/output.go
new file mode 100644
index 00000000..71acace4
--- /dev/null
+++ b/internal/system/output.go
@@ -0,0 +1,88 @@
+package system
+
+import (
+ "errors"
+ "net"
+ "os"
+
+ "hakurei.app/container"
+ "hakurei.app/message"
+)
+
+// OpError is returned by [I.Commit] and [I.Revert].
+type OpError struct {
+ Op string
+ Err error
+ Msg string
+ Revert bool
+}
+
+func (e *OpError) Unwrap() error { return e.Err }
+func (e *OpError) Error() string {
+ if e.Msg != "" {
+ return e.Msg
+ }
+
+ switch {
+ case errors.As(e.Err, new(*os.PathError)),
+ errors.As(e.Err, new(*os.LinkError)),
+ errors.As(e.Err, new(*net.OpError)),
+ errors.As(e.Err, new(*container.StartError)):
+ return e.Err.Error()
+
+ default:
+ if !e.Revert {
+ return "apply " + e.Op + ": " + e.Err.Error()
+ } else {
+ return "revert " + e.Op + ": " + e.Err.Error()
+ }
+ }
+}
+
+func (e *OpError) Message() string {
+ switch {
+ case e.Msg != "":
+ return e.Error()
+
+ default:
+ return "cannot " + e.Error()
+ }
+}
+
+// newOpError returns an [OpError] without a message string.
+func newOpError(op string, err error, revert bool) error {
+ if err == nil {
+ return nil
+ }
+ return &OpError{op, err, "", revert}
+}
+
+// newOpErrorMessage returns an [OpError] with an overriding message string.
+func newOpErrorMessage(op string, err error, message string, revert bool) error {
+ if err == nil {
+ return nil
+ }
+ return &OpError{op, err, message, revert}
+}
+
+func printJoinedError(println func(v ...any), fallback string, err error) {
+ var joinErr interface {
+ Unwrap() []error
+ error
+ }
+ if !errors.As(err, &joinErr) {
+ if m, ok := message.GetMessage(err); ok {
+ println(m)
+ } else {
+ println(fallback, err)
+ }
+ } else {
+ for _, err = range joinErr.Unwrap() {
+ if m, ok := message.GetMessage(err); ok {
+ println(m)
+ } else {
+ println(err.Error())
+ }
+ }
+ }
+}
diff --git a/internal/system/output_test.go b/internal/system/output_test.go
new file mode 100644
index 00000000..c0cbfdfe
--- /dev/null
+++ b/internal/system/output_test.go
@@ -0,0 +1,140 @@
+package system
+
+import (
+ "errors"
+ "net"
+ "os"
+ "reflect"
+ "syscall"
+ "testing"
+
+ "hakurei.app/container"
+ "hakurei.app/message"
+)
+
+func TestOpError(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ err error
+ s string
+ is error
+ isF error
+ msg string
+ }{
+ {"message", newOpErrorMessage("dbus", ErrDBusConfig,
+ "attempted to create message bus proxy args without session bus config", false),
+ "attempted to create message bus proxy args without session bus config",
+ ErrDBusConfig, syscall.ENOTRECOVERABLE,
+ "attempted to create message bus proxy args without session bus config"},
+
+ {"apply", newOpError("tmpfile", syscall.EBADE, false),
+ "apply tmpfile: invalid exchange",
+ syscall.EBADE, syscall.EBADF,
+ "cannot apply tmpfile: invalid exchange"},
+
+ {"revert", newOpError("wayland", syscall.EBADF, true),
+ "revert wayland: bad file descriptor",
+ syscall.EBADF, syscall.EBADE,
+ "cannot revert wayland: bad file descriptor"},
+
+ {"path", newOpError("tmpfile", &os.PathError{Op: "stat", Path: "/run/dbus", Err: syscall.EISDIR}, false),
+ "stat /run/dbus: is a directory",
+ syscall.EISDIR, syscall.ENOTDIR,
+ "cannot stat /run/dbus: is a directory"},
+
+ {"net", newOpError("wayland", &net.OpError{Op: "dial", Net: "unix", Addr: &net.UnixAddr{Name: "/run/user/1000/wayland-1", Net: "unix"}, Err: syscall.ENOENT}, false),
+ "dial unix /run/user/1000/wayland-1: no such file or directory",
+ syscall.ENOENT, syscall.EPERM,
+ "cannot dial unix /run/user/1000/wayland-1: no such file or directory"},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("error", func(t *testing.T) {
+ if got := tc.err.Error(); got != tc.s {
+ t.Errorf("Error: %q, want %q", got, tc.s)
+ }
+ })
+
+ t.Run("is", func(t *testing.T) {
+ if !errors.Is(tc.err, tc.is) {
+ t.Error("Is: unexpected false")
+ }
+ if errors.Is(tc.err, tc.isF) {
+ t.Error("Is: unexpected true")
+ }
+ })
+
+ t.Run("msg", func(t *testing.T) {
+ if got, ok := message.GetMessage(tc.err); !ok {
+ if tc.msg != "" {
+ t.Errorf("GetMessage: err does not implement MessageError")
+ }
+ return
+ } else if got != tc.msg {
+ t.Errorf("GetMessage: %q, want %q", got, tc.msg)
+ }
+ })
+ })
+ }
+
+ t.Run("new", func(t *testing.T) {
+ if err := newOpError("check", nil, false); err != nil {
+ t.Errorf("newOpError: %v", err)
+ }
+ if err := newOpErrorMessage("check", nil, "", false); err != nil {
+ t.Errorf("newOpErrorMessage: %v", err)
+ }
+ })
+}
+
+func TestPrintJoinedError(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ err error
+ want [][]any
+ }{
+ {"nil", nil, [][]any{{"not a joined error:", nil}}},
+ {"single", errors.Join(syscall.EINVAL), [][]any{{"invalid argument"}}},
+
+ {"unwrapped", syscall.EINVAL, [][]any{{"not a joined error:", syscall.EINVAL}}},
+ {"unwrapped message", &OpError{
+ Op: "meow",
+ Err: syscall.EBADFD,
+ }, [][]any{
+ {"cannot apply meow: file descriptor in bad state"},
+ }},
+
+ {"many", errors.Join(syscall.ENOTRECOVERABLE, syscall.ETIMEDOUT, syscall.EBADFD), [][]any{
+ {"state not recoverable"},
+ {"connection timed out"},
+ {"file descriptor in bad state"},
+ }},
+ {"many message", errors.Join(
+ &container.StartError{Step: "meow", Err: syscall.ENOMEM},
+ &os.PathError{Op: "meow", Path: "/proc/nonexistent", Err: syscall.ENOSYS},
+ &os.LinkError{Op: "link", Old: "/etc", New: "/proc/nonexistent", Err: syscall.ENOENT},
+ &OpError{Op: "meow", Err: syscall.ENODEV, Revert: true},
+ ), [][]any{
+ {"cannot meow: cannot allocate memory"},
+ {"meow /proc/nonexistent: function not implemented"},
+ {"link /etc /proc/nonexistent: no such file or directory"},
+ {"cannot revert meow: no such device"},
+ }},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ var got [][]any
+ printJoinedError(func(v ...any) { got = append(got, v) }, "not a joined error:", tc.err)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Errorf("printJoinedError: %#v, want %#v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/system/system.go b/internal/system/system.go
new file mode 100644
index 00000000..f518975c
--- /dev/null
+++ b/internal/system/system.go
@@ -0,0 +1,177 @@
+// Package system provides helpers to apply and revert groups of operations to the system.
+package system
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "hakurei.app/hst"
+ "hakurei.app/message"
+)
+
+const (
+ // User type is reverted at final instance exit.
+ User = hst.EM << iota
+ // Process type is unconditionally reverted on exit.
+ Process
+
+ CM
+)
+
+// Criteria specifies types of Op to revert.
+type Criteria hst.Enablement
+
+func (ec *Criteria) hasType(t hst.Enablement) bool {
+ // nil criteria: revert everything except User
+ if ec == nil {
+ return t != User
+ }
+
+ return hst.Enablement(*ec)&t != 0
+}
+
+// Op is a reversible system operation.
+type Op interface {
+ // Type returns [Op]'s enablement type, for matching a revert criteria.
+ Type() hst.Enablement
+
+ apply(sys *I) error
+ revert(sys *I, ec *Criteria) error
+
+ Is(o Op) bool
+ Path() string
+ String() string
+}
+
+// TypeString extends [hst.Enablement.String] to support [User] and [Process].
+func TypeString(e hst.Enablement) string {
+ switch e {
+ case User:
+ return "user"
+ case Process:
+ return "process"
+ default:
+ buf := new(strings.Builder)
+ buf.Grow(48)
+ if v := e &^ User &^ Process; v != 0 {
+ buf.WriteString(v.String())
+ }
+
+ for i := User; i < CM; i <<= 1 {
+ if e&i != 0 {
+ buf.WriteString(", " + TypeString(i))
+ }
+ }
+ return strings.TrimPrefix(buf.String(), ", ")
+ }
+}
+
+// New returns the address of a new [I] targeting uid.
+func New(ctx context.Context, msg message.Msg, uid int) (sys *I) {
+ if ctx == nil || msg == nil || uid < 0 {
+ panic("invalid call to New")
+ }
+ return &I{ctx: ctx, msg: msg, uid: uid, syscallDispatcher: direct{}}
+}
+
+// An I provides deferred operating system interaction. [I] must not be copied.
+// Methods of [I] must not be used concurrently.
+type I struct {
+ _ noCopy
+
+ uid int
+ ops []Op
+ ctx context.Context
+
+ // the behaviour of Commit is only defined for up to one call
+ committed bool
+ // the behaviour of Revert is only defined for up to one call
+ reverted bool
+
+ msg message.Msg
+ syscallDispatcher
+}
+
+func (sys *I) UID() int { return sys.uid }
+
+// Equal returns whether all [Op] instances held by sys matches that of target.
+func (sys *I) Equal(target *I) bool {
+ if sys == nil || target == nil || sys.uid != target.uid || len(sys.ops) != len(target.ops) {
+ return false
+ }
+
+ for i, o := range sys.ops {
+ if !o.Is(target.ops[i]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// Commit applies all [Op] held by [I] and reverts all successful [Op] on first error encountered.
+// Commit must not be called more than once.
+func (sys *I) Commit() error {
+ if sys.committed {
+ panic("attempting to commit twice")
+ }
+ sys.committed = true
+
+ sp := New(sys.ctx, sys.msg, sys.uid)
+ sp.syscallDispatcher = sys.syscallDispatcher
+ 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.msg.Verbosef("commit faulted after %d ops, rolling back partial commit", len(sp.ops))
+ if err := sp.Revert(nil); err != nil {
+ printJoinedError(sys.println, "cannot revert 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
+}
+
+// Revert reverts all [Op] meeting [Criteria] held by [I].
+func (sys *I) Revert(ec *Criteria) error {
+ if sys.reverted {
+ panic("attempting to revert twice")
+ }
+ sys.reverted = 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...)
+}
+
+// noCopy may be added to structs which must not be copied
+// after the first use.
+//
+// See https://golang.org/issues/8005#issuecomment-190753527
+// for details.
+//
+// Note that it must not be embedded, due to the Lock and Unlock methods.
+type noCopy struct{}
+
+// Lock is a no-op used by -copylocks checker from `go vet`.
+func (*noCopy) Lock() {}
+func (*noCopy) Unlock() {}
diff --git a/internal/system/system_test.go b/internal/system/system_test.go
new file mode 100644
index 00000000..b0773b0f
--- /dev/null
+++ b/internal/system/system_test.go
@@ -0,0 +1,331 @@
+package system
+
+import (
+ "errors"
+ "os"
+ "reflect"
+ "slices"
+ "strconv"
+ "testing"
+
+ "hakurei.app/container/check"
+ "hakurei.app/container/stub"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/xcb"
+ "hakurei.app/message"
+)
+
+func TestCriteria(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ ec, t hst.Enablement
+ want bool
+ }{
+ {"nil", 0xff, hst.EWayland, true},
+ {"nil user", 0xff, User, false},
+ {"all", hst.EWayland | hst.EX11 | hst.EDBus | hst.EPulse | User | Process, Process, true},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ var criteria *Criteria
+ if tc.ec != 0xff {
+ criteria = (*Criteria)(&tc.ec)
+ }
+ if got := criteria.hasType(tc.t); got != tc.want {
+ t.Errorf("hasType: got %v, want %v",
+ got, tc.want)
+ }
+ })
+ }
+}
+
+func TestTypeString(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ e hst.Enablement
+ want string
+ }{
+ {hst.EWayland, hst.EWayland.String()},
+ {hst.EX11, hst.EX11.String()},
+ {hst.EDBus, hst.EDBus.String()},
+ {hst.EPulse, hst.EPulse.String()},
+ {User, "user"},
+ {Process, "process"},
+ {User | Process, "user, process"},
+ {hst.EWayland | User | Process, "wayland, user, process"},
+ {hst.EX11 | Process, "x11, process"},
+ }
+
+ for _, tc := range testCases {
+ t.Run("label type string "+strconv.Itoa(int(tc.e)), func(t *testing.T) {
+ t.Parallel()
+ if got := TypeString(tc.e); got != tc.want {
+ t.Errorf("TypeString: %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestNew(t *testing.T) {
+ t.Parallel()
+
+ t.Run("panic", func(t *testing.T) {
+ t.Run("ctx", func(t *testing.T) {
+ defer func() {
+ want := "invalid call to New"
+ if r := recover(); r != want {
+ t.Errorf("recover: %v, want %v", r, want)
+ }
+ }()
+ New(nil, message.New(nil), 0)
+ })
+
+ t.Run("msg", func(t *testing.T) {
+ defer func() {
+ want := "invalid call to New"
+ if r := recover(); r != want {
+ t.Errorf("recover: %v, want %v", r, want)
+ }
+ }()
+ New(t.Context(), nil, 0)
+ })
+
+ t.Run("uid", func(t *testing.T) {
+ defer func() {
+ want := "invalid call to New"
+ if r := recover(); r != want {
+ t.Errorf("recover: %v, want %v", r, want)
+ }
+ }()
+ New(t.Context(), message.New(nil), -1)
+ })
+ })
+
+ sys := New(t.Context(), message.New(nil), 0xbeef)
+ if sys.ctx == nil {
+ t.Error("New: ctx = nil")
+ }
+ if got := sys.UID(); got != 0xbeef {
+ t.Errorf("UID: %d", got)
+ }
+}
+
+func TestEqual(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ sys *I
+ v *I
+ want bool
+ }{
+ {"simple UID",
+ New(t.Context(), message.New(nil), 150),
+ New(t.Context(), message.New(nil), 150),
+ true},
+
+ {"simple UID differ",
+ New(t.Context(), message.New(nil), 150),
+ New(t.Context(), message.New(nil), 151),
+ false},
+
+ {"simple UID nil",
+ New(t.Context(), message.New(nil), 150),
+ nil,
+ false},
+
+ {"op length mismatch",
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos"),
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Ensure(m("/run"), 0755),
+ false},
+
+ {"op value mismatch",
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Ensure(m("/run"), 0644),
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Ensure(m("/run"), 0755),
+ false},
+
+ {"op type mismatch",
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Wayland(m("/proc/nonexistent/dst"), m("/proc/nonexistent/src"), "\x00", "\x00"),
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Ensure(m("/run"), 0755),
+ false},
+
+ {"op equals",
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Ensure(m("/run"), 0755),
+ New(t.Context(), message.New(nil), 150).
+ ChangeHosts("chronos").
+ Ensure(m("/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: %v, want %v", !tc.want, tc.want)
+ }
+ })
+ }
+}
+
+func TestCommitRevert(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ f func(sys *I)
+ ec hst.Enablement
+
+ commit []stub.Call
+ wantErrCommit error
+
+ revert []stub.Call
+ wantErrRevert error
+ }{
+ {"apply xhost partial mkdir", func(sys *I) {
+ sys.
+ Ephemeral(Process, m("/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"), 0711).
+ ChangeHosts("chronos")
+ }, 0xff, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, stub.UniqueError(2)),
+ call("verbosef", stub.ExpectArgs{"commit faulted after %d ops, rolling back partial commit", []any{1}}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"destroying ephemeral directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"}, nil, stub.UniqueError(3)),
+ call("println", stub.ExpectArgs{[]any{"cannot revert mkdir: unique error 3 injected by the test suite"}}, nil, nil),
+ }, &OpError{Op: "xhost", Err: stub.UniqueError(2)}, nil, nil},
+
+ {"apply xhost", func(sys *I) {
+ sys.
+ Ephemeral(Process, m("/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"), 0711).
+ ChangeHosts("chronos")
+ }, 0xff, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, stub.UniqueError(2)),
+ call("verbosef", stub.ExpectArgs{"commit faulted after %d ops, rolling back partial commit", []any{1}}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"destroying ephemeral directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"}, nil, nil),
+ }, &OpError{Op: "xhost", Err: stub.UniqueError(2)}, nil, nil},
+
+ {"revert multi", func(sys *I) {
+ sys.
+ Ephemeral(Process, m("/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"), 0711).
+ ChangeHosts("chronos")
+ }, 0xff, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"deleting entry %s from X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeDelete), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, stub.UniqueError(1)),
+ call("verbose", stub.ExpectArgs{[]any{"destroying ephemeral directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"}, nil, stub.UniqueError(0)),
+ }, errors.Join(
+ &OpError{Op: "xhost", Err: stub.UniqueError(1), Revert: true},
+ &OpError{Op: "mkdir", Err: stub.UniqueError(0), Revert: true})},
+
+ {"success", func(sys *I) {
+ sys.
+ Ephemeral(Process, m("/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"), 0711).
+ ChangeHosts("chronos")
+ }, 0xff, []stub.Call{
+ call("verbose", stub.ExpectArgs{[]any{"ensuring directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("mkdir", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", os.FileMode(0711)}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"deleting entry %s from X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeDelete), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ call("verbose", stub.ExpectArgs{[]any{"destroying ephemeral directory", &mkdirOp{Process, "/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9", 0711, true}}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.0/f2f3bcd492d0266438fa9bf164fe90d9"}, nil, nil),
+ }, nil},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var ec *Criteria
+ if tc.ec != 0xff {
+ ec = (*Criteria)(&tc.ec)
+ }
+
+ sys, s := InternalNew(t, stub.Expect{Calls: slices.Concat(tc.commit, []stub.Call{{Name: stub.CallSeparator}}, tc.revert)}, 0xbad)
+ defer stub.HandleExit(t)
+ tc.f(sys)
+ errCommit := sys.Commit()
+ s.Expects(stub.CallSeparator)
+ if !reflect.DeepEqual(errCommit, tc.wantErrCommit) {
+ t.Errorf("Commit: error = %v, want %v", errCommit, tc.wantErrCommit)
+ }
+ if errCommit != nil {
+ goto out
+ }
+
+ if err := sys.Revert(ec); !reflect.DeepEqual(err, tc.wantErrRevert) {
+ t.Errorf("Revert: error = %v, want %v", err, tc.wantErrRevert)
+ }
+
+ out:
+ s.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) {
+ count := s.Pos() - 1 // separator
+ if count < len(tc.commit) {
+ t.Errorf("Commit: %d calls, want %d", count, len(tc.commit))
+ } else {
+ t.Errorf("Revert: %d calls, want %d", count-len(tc.commit), len(tc.revert))
+ }
+ })
+ })
+ }
+
+ t.Run("panic", func(t *testing.T) {
+ t.Run("committed", func(t *testing.T) {
+ defer func() {
+ want := "attempting to commit twice"
+ if r := recover(); r != want {
+ t.Errorf("Commit: panic = %v, want %v", r, want)
+ }
+ }()
+ _ = (&I{committed: true}).Commit()
+ })
+
+ t.Run("reverted", func(t *testing.T) {
+ defer func() {
+ want := "attempting to revert twice"
+ if r := recover(); r != want {
+ t.Errorf("Revert: panic = %v, want %v", r, want)
+ }
+ }()
+ _ = (&I{reverted: true}).Revert(nil)
+ })
+ })
+}
+
+func TestNop(t *testing.T) {
+ // these do nothing
+ new(noCopy).Unlock()
+ new(noCopy).Lock()
+}
+
+func m(pathname string) *check.Absolute { return check.MustAbs(pathname) }
diff --git a/internal/system/wayland.go b/internal/system/wayland.go
new file mode 100644
index 00000000..8883148e
--- /dev/null
+++ b/internal/system/wayland.go
@@ -0,0 +1,90 @@
+package system
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "hakurei.app/container/check"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/acl"
+ "hakurei.app/internal/system/wayland"
+)
+
+type waylandConn interface {
+ Attach(p string) (err error)
+ Bind(pathname, appID, instanceID string) (*os.File, error)
+ Close() error
+}
+
+// Wayland maintains a wayland socket with security-context-v1 attached via [wayland].
+// The socket stops accepting connections once the pipe referred to by sync is closed.
+// The socket is pathname only and is destroyed on revert.
+func (sys *I) Wayland(dst, src *check.Absolute, appID, instanceID string) *I {
+ sys.ops = append(sys.ops, &waylandOp{nil,
+ dst.String(), src.String(),
+ appID, instanceID,
+ new(wayland.Conn)})
+ return sys
+}
+
+// waylandOp implements [I.Wayland].
+type waylandOp struct {
+ sync *os.File
+ dst, src string
+ appID, instanceID string
+
+ conn waylandConn
+}
+
+func (w *waylandOp) Type() hst.Enablement { return Process }
+
+func (w *waylandOp) apply(sys *I) error {
+ if err := w.conn.Attach(w.src); err != nil {
+ return newOpError("wayland", err, false)
+ } else {
+ sys.msg.Verbosef("wayland attached on %q", w.src)
+ }
+
+ if sp, err := w.conn.Bind(w.dst, w.appID, w.instanceID); err != nil {
+ return newOpError("wayland", err, false)
+ } else {
+ w.sync = sp
+ sys.msg.Verbosef("wayland listening on %q", w.dst)
+ if err = sys.chmod(w.dst, 0); err != nil {
+ return newOpError("wayland", err, false)
+ }
+ return newOpError("wayland", sys.aclUpdate(w.dst, sys.uid, acl.Read, acl.Write, acl.Execute), false)
+ }
+}
+
+func (w *waylandOp) revert(sys *I, _ *Criteria) error {
+ var (
+ hangupErr error
+ closeErr error
+ removeErr error
+ )
+
+ sys.msg.Verbosef("detaching from wayland on %q", w.src)
+ if w.sync != nil {
+ hangupErr = w.sync.Close()
+ }
+ closeErr = w.conn.Close()
+
+ sys.msg.Verbosef("removing wayland socket on %q", w.dst)
+ if err := sys.remove(w.dst); err != nil && !errors.Is(err, os.ErrNotExist) {
+ removeErr = err
+ }
+
+ return newOpError("wayland", errors.Join(hangupErr, closeErr, removeErr), true)
+}
+
+func (w *waylandOp) Is(o Op) bool {
+ target, ok := o.(*waylandOp)
+ return ok && w != nil && target != nil &&
+ w.dst == target.dst && w.src == target.src &&
+ w.appID == target.appID && w.instanceID == target.instanceID
+}
+
+func (w *waylandOp) Path() string { return w.dst }
+func (w *waylandOp) String() string { return fmt.Sprintf("wayland socket at %q", w.dst) }
diff --git a/internal/system/wayland/conn.go b/internal/system/wayland/conn.go
new file mode 100644
index 00000000..c55fe704
--- /dev/null
+++ b/internal/system/wayland/conn.go
@@ -0,0 +1,122 @@
+package wayland
+
+import (
+ "errors"
+ "net"
+ "os"
+ "runtime"
+ "sync"
+ "syscall"
+)
+
+// Conn represents a connection to the wayland display server.
+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("socket already 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
+}
+
+// Bind binds the new socket to pathname.
+func (c *Conn) Bind(pathname, appID, instanceID string) (*os.File, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.conn == nil {
+ return nil, errors.New("socket not attached")
+ }
+ if c.done != nil {
+ return nil, errors.New("socket already bound")
+ }
+
+ if rc, err := c.conn.SyscallConn(); err != nil {
+ // unreachable
+ return nil, err
+ } else {
+ c.done = make(chan struct{})
+ return bindRawConn(c.done, rc, pathname, 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/internal/system/wayland/security-context-v1-protocol.c b/internal/system/wayland/security-context-v1-protocol.c
new file mode 100644
index 00000000..5b15b1b4
--- /dev/null
+++ b/internal/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/internal/system/wayland/security-context-v1-protocol.h b/internal/system/wayland/security-context-v1-protocol.h
new file mode 100644
index 00000000..f973619e
--- /dev/null
+++ b/internal/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/internal/system/wayland/wayland-client-helper.c b/internal/system/wayland/wayland-client-helper.c
new file mode 100644
index 00000000..5a2c9a01
--- /dev/null
+++ b/internal/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/internal/system/wayland/wayland-client-helper.h b/internal/system/wayland/wayland-client-helper.h
new file mode 100644
index 00000000..eae8936d
--- /dev/null
+++ b/internal/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/internal/system/wayland/wayland.go b/internal/system/wayland/wayland.go
new file mode 100644
index 00000000..7a8694cd
--- /dev/null
+++ b/internal/system/wayland/wayland.go
@@ -0,0 +1,48 @@
+// Package wayland implements Wayland security_context_v1 protocol.
+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"
+ "syscall"
+)
+
+const (
+ // Display 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).
+ Display = "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"
+)
+
+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 syscall.EINVAL
+ }
+ 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, 0) > -1 }
diff --git a/internal/system/wayland_test.go b/internal/system/wayland_test.go
new file mode 100644
index 00000000..083c8ba7
--- /dev/null
+++ b/internal/system/wayland_test.go
@@ -0,0 +1,303 @@
+package system
+
+import (
+ "errors"
+ "os"
+ "testing"
+
+ "hakurei.app/container/stub"
+ "hakurei.app/internal/system/acl"
+ "hakurei.app/internal/system/wayland"
+)
+
+type stubWaylandConn struct {
+ t *testing.T
+
+ wantAttach string
+ attachErr error
+ attached bool
+
+ wantBind [3]string
+ bindErr error
+ bound bool
+
+ closeErr error
+ closed bool
+}
+
+func (conn *stubWaylandConn) Attach(p string) (err error) {
+ conn.t.Helper()
+
+ if conn.attached {
+ conn.t.Fatal("Attach called twice")
+ }
+ conn.attached = true
+
+ err = conn.attachErr
+ if p != conn.wantAttach {
+ conn.t.Errorf("Attach: p = %q, want %q", p, conn.wantAttach)
+ err = stub.ErrCheck
+ }
+ return
+}
+
+func (conn *stubWaylandConn) Bind(pathname, appID, instanceID string) (*os.File, error) {
+ conn.t.Helper()
+
+ if !conn.attached {
+ conn.t.Fatal("Bind called before Attach")
+ }
+
+ if conn.bound {
+ conn.t.Fatal("Bind called twice")
+ }
+ conn.bound = true
+
+ if pathname != conn.wantBind[0] {
+ conn.t.Errorf("Attach: pathname = %q, want %q", pathname, conn.wantBind[0])
+ return nil, stub.ErrCheck
+ }
+ if appID != conn.wantBind[1] {
+ conn.t.Errorf("Attach: appID = %q, want %q", appID, conn.wantBind[1])
+ return nil, stub.ErrCheck
+ }
+ if instanceID != conn.wantBind[2] {
+ conn.t.Errorf("Attach: instanceID = %q, want %q", instanceID, conn.wantBind[2])
+ return nil, stub.ErrCheck
+ }
+ return nil, conn.bindErr
+}
+
+func (conn *stubWaylandConn) Close() error {
+ conn.t.Helper()
+
+ if !conn.attached {
+ conn.t.Fatal("Close called before Attach")
+ }
+ if !conn.bound {
+ conn.t.Fatal("Close called before Bind")
+ }
+
+ if conn.closed {
+ conn.t.Fatal("Close called twice")
+ }
+ conn.closed = true
+ return conn.closeErr
+}
+
+func TestWaylandOp(t *testing.T) {
+ t.Parallel()
+
+ checkOpBehaviour(t, []opBehaviourTestCase{
+ {"attach", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"},
+ attachErr: stub.UniqueError(5)},
+ }, nil, &OpError{Op: "wayland", Err: stub.UniqueError(5)}, nil, nil},
+
+ {"bind", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"},
+ bindErr: stub.UniqueError(4)},
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"wayland attached on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ }, &OpError{Op: "wayland", Err: stub.UniqueError(4)}, nil, nil},
+
+ {"chmod", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"}},
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"wayland attached on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"wayland listening on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", os.FileMode(0)}, nil, stub.UniqueError(3)),
+ }, &OpError{Op: "wayland", Err: stub.UniqueError(3)}, nil, nil},
+
+ {"aclUpdate", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"}},
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"wayland attached on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"wayland listening on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", os.FileMode(0)}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, stub.UniqueError(2)),
+ }, &OpError{Op: "wayland", Err: stub.UniqueError(2)}, nil, nil},
+
+ {"remove", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"}},
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"wayland attached on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"wayland listening on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", os.FileMode(0)}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"detaching from wayland on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"removing wayland socket on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}, nil, stub.UniqueError(1)),
+ }, &OpError{Op: "wayland", Err: errors.Join(stub.UniqueError(1)), Revert: true}},
+
+ {"close", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"},
+ closeErr: stub.UniqueError(0)},
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"wayland attached on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"wayland listening on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", os.FileMode(0)}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"detaching from wayland on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"removing wayland socket on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}, nil, nil),
+ }, &OpError{Op: "wayland", Err: errors.Join(stub.UniqueError(0)), Revert: true}},
+
+ {"success", 0xbeef, 0xff, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ &stubWaylandConn{t: t, wantAttach: "/run/user/1971/wayland-0", wantBind: [3]string{
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"}},
+ }, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"wayland attached on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"wayland listening on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("chmod", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", os.FileMode(0)}, nil, nil),
+ call("aclUpdate", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", 0xbeef, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"detaching from wayland on %q", []any{"/run/user/1971/wayland-0"}}, nil, nil),
+ call("verbosef", stub.ExpectArgs{"removing wayland socket on %q", []any{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}}, nil, nil),
+ call("remove", stub.ExpectArgs{"/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"}, nil, nil),
+ }, nil},
+ })
+
+ checkOpsBuilder(t, "Wayland", []opsBuilderTestCase{
+ {"chromium", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.Wayland(
+ m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"),
+ m("/run/user/1971/wayland-0"),
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ )
+ }, []Op{&waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }}, stub.Expect{}},
+ })
+
+ checkOpIs(t, []opIsTestCase{
+ {"dst differs", &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7d/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, false},
+
+ {"src differs", &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-1",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, false},
+
+ {"appID differs", &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, false},
+
+ {"instanceID differs", &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7d",
+ new(wayland.Conn),
+ }, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, false},
+
+ {"equals", &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, true},
+ })
+
+ checkOpMeta(t, []opMetaTestCase{
+ {"chromium", &waylandOp{nil,
+ "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ "/run/user/1971/wayland-0",
+ "org.chromium.Chromium",
+ "ebf083d1b175911782d413369b64ce7c",
+ new(wayland.Conn),
+ }, Process, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland",
+ `wayland socket at "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"`},
+ })
+}
diff --git a/internal/system/xcb/changehosts.go b/internal/system/xcb/changehosts.go
new file mode 100644
index 00000000..8b05614b
--- /dev/null
+++ b/internal/system/xcb/changehosts.go
@@ -0,0 +1,17 @@
+package xcb
+
+import "errors"
+
+var ErrChangeHosts = errors.New("xcb_change_hosts() failed")
+
+func ChangeHosts(mode HostMode, family Family, address string) error {
+ conn := new(connection)
+ if err := conn.connect(); err != nil {
+ conn.disconnect()
+ return err
+ } else {
+ defer conn.disconnect()
+ }
+
+ return conn.changeHostsChecked(mode, family, address)
+}
diff --git a/internal/system/xcb/xcb.go b/internal/system/xcb/xcb.go
new file mode 100644
index 00000000..3dc62c3f
--- /dev/null
+++ b/internal/system/xcb/xcb.go
@@ -0,0 +1,127 @@
+// Package xcb implements X11 ChangeHosts via libxcb.
+package xcb
+
+import (
+ "runtime"
+ "unsafe"
+)
+
+/*
+#cgo linux pkg-config: --static xcb
+
+#include <stdlib.h>
+#include <xcb/xcb.h>
+
+static int hakurei_xcb_change_hosts_checked(xcb_connection_t *c,
+ uint8_t mode, uint8_t family,
+ uint16_t address_len, const uint8_t *address) {
+ int ret;
+ xcb_generic_error_t *e;
+ xcb_void_cookie_t cookie;
+
+ cookie = xcb_change_hosts_checked(c, mode, family, address_len, address);
+ free((void *)address);
+
+ ret = xcb_connection_has_error(c);
+ if (ret != 0)
+ return ret;
+
+ e = xcb_request_check(c, cookie);
+ if (e != NULL) {
+ // don't want to deal with xcb errors
+ free((void *)e);
+ ret = -1;
+ }
+
+ return ret;
+}
+*/
+import "C"
+
+const (
+ HostModeInsert = C.XCB_HOST_MODE_INSERT
+ HostModeDelete = C.XCB_HOST_MODE_DELETE
+
+ FamilyInternet = C.XCB_FAMILY_INTERNET
+ FamilyDecnet = C.XCB_FAMILY_DECNET
+ FamilyChaos = C.XCB_FAMILY_CHAOS
+ FamilyServerInterpreted = C.XCB_FAMILY_SERVER_INTERPRETED
+ FamilyInternet6 = C.XCB_FAMILY_INTERNET_6
+)
+
+type (
+ HostMode = C.xcb_host_mode_t
+ Family = C.xcb_family_t
+)
+
+func (conn *connection) changeHostsChecked(mode HostMode, family Family, address string) error {
+ ret := C.hakurei_xcb_change_hosts_checked(
+ conn.c,
+ C.uint8_t(mode),
+ C.uint8_t(family),
+ C.uint16_t(len(address)),
+ (*C.uint8_t)(unsafe.Pointer(C.CString(address))),
+ )
+ switch ret {
+ case 0:
+ return nil
+ case -1:
+ return ErrChangeHosts
+ default:
+ return ConnectionError(ret)
+ }
+}
+
+type connection struct{ c *C.xcb_connection_t }
+
+func (conn *connection) connect() error {
+ conn.c = C.xcb_connect(nil, nil)
+ runtime.SetFinalizer(conn, (*connection).disconnect)
+ return conn.hasError()
+}
+
+func (conn *connection) hasError() error {
+ ret := C.xcb_connection_has_error(conn.c)
+ if ret == 0 {
+ return nil
+ }
+ return ConnectionError(ret)
+}
+
+func (conn *connection) disconnect() {
+ C.xcb_disconnect(conn.c)
+
+ // no need for a finalizer anymore
+ runtime.SetFinalizer(conn, nil)
+}
+
+const (
+ ConnError ConnectionError = C.XCB_CONN_ERROR
+ ConnClosedExtNotSupported ConnectionError = C.XCB_CONN_CLOSED_EXT_NOTSUPPORTED
+ ConnClosedMemInsufficient ConnectionError = C.XCB_CONN_CLOSED_MEM_INSUFFICIENT
+ ConnClosedReqLenExceed ConnectionError = C.XCB_CONN_CLOSED_REQ_LEN_EXCEED
+ ConnClosedParseErr ConnectionError = C.XCB_CONN_CLOSED_PARSE_ERR
+ ConnClosedInvalidScreen ConnectionError = C.XCB_CONN_CLOSED_INVALID_SCREEN
+)
+
+// ConnectionError represents an error returned by xcb_connection_has_error.
+type ConnectionError int
+
+func (ce ConnectionError) Error() string {
+ switch ce {
+ case ConnError:
+ return "connection error"
+ case ConnClosedExtNotSupported:
+ return "extension not supported"
+ case ConnClosedMemInsufficient:
+ return "memory not available"
+ case ConnClosedReqLenExceed:
+ return "request length exceeded"
+ case ConnClosedParseErr:
+ return "invalid display string"
+ case ConnClosedInvalidScreen:
+ return "server has no screen matching display"
+ default:
+ return "generic X11 failure"
+ }
+}
diff --git a/internal/system/xhost.go b/internal/system/xhost.go
new file mode 100644
index 00000000..27e60c2b
--- /dev/null
+++ b/internal/system/xhost.go
@@ -0,0 +1,38 @@
+package system
+
+import (
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/xcb"
+)
+
+// ChangeHosts inserts the target user into X11 hosts and deletes it once its [Enablement] is no longer satisfied.
+func (sys *I) ChangeHosts(username string) *I {
+ sys.ops = append(sys.ops, xhostOp(username))
+ return sys
+}
+
+// xhostOp implements [I.ChangeHosts].
+type xhostOp string
+
+func (x xhostOp) Type() hst.Enablement { return hst.EX11 }
+
+func (x xhostOp) apply(sys *I) error {
+ sys.msg.Verbosef("inserting entry %s to X11", x)
+ return newOpError("xhost",
+ sys.xcbChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)), false)
+}
+
+func (x xhostOp) revert(sys *I, ec *Criteria) error {
+ if ec.hasType(x.Type()) {
+ sys.msg.Verbosef("deleting entry %s from X11", x)
+ return newOpError("xhost",
+ sys.xcbChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)), true)
+ } else {
+ sys.msg.Verbosef("skipping entry %s in X11", x)
+ return nil
+ }
+}
+
+func (x xhostOp) Is(o Op) bool { target, ok := o.(xhostOp); return ok && x == target }
+func (x xhostOp) Path() string { return "/tmp/.X11-unix" }
+func (x xhostOp) String() string { return string("SI:localuser:" + x) }
diff --git a/internal/system/xhost_test.go b/internal/system/xhost_test.go
new file mode 100644
index 00000000..853da651
--- /dev/null
+++ b/internal/system/xhost_test.go
@@ -0,0 +1,60 @@
+package system
+
+import (
+ "testing"
+
+ "hakurei.app/container/stub"
+ "hakurei.app/hst"
+ "hakurei.app/internal/system/xcb"
+)
+
+func TestXHostOp(t *testing.T) {
+ t.Parallel()
+
+ checkOpBehaviour(t, []opBehaviourTestCase{
+ {"xcbChangeHosts revert", 0xbeef, hst.EX11, xhostOp("chronos"), []stub.Call{
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, stub.UniqueError(1)),
+ }, &OpError{Op: "xhost", Err: stub.UniqueError(1)}, nil, nil},
+
+ {"xcbChangeHosts revert", 0xbeef, hst.EX11, xhostOp("chronos"), []stub.Call{
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"deleting entry %s from X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeDelete), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, stub.UniqueError(0)),
+ }, &OpError{Op: "xhost", Err: stub.UniqueError(0), Revert: true}},
+
+ {"success skip", 0xbeef, 0, xhostOp("chronos"), []stub.Call{
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"skipping entry %s in X11", []any{xhostOp("chronos")}}, nil, nil),
+ }, nil},
+
+ {"success", 0xbeef, hst.EX11, xhostOp("chronos"), []stub.Call{
+ call("verbosef", stub.ExpectArgs{"inserting entry %s to X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeInsert), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ }, nil, []stub.Call{
+ call("verbosef", stub.ExpectArgs{"deleting entry %s from X11", []any{xhostOp("chronos")}}, nil, nil),
+ call("xcbChangeHosts", stub.ExpectArgs{xcb.HostMode(xcb.HostModeDelete), xcb.Family(xcb.FamilyServerInterpreted), "localuser\x00chronos"}, nil, nil),
+ }, nil},
+ })
+
+ checkOpsBuilder(t, "ChangeHosts", []opsBuilderTestCase{
+ {"xhost", 0xcafe, func(_ *testing.T, sys *I) {
+ sys.ChangeHosts("chronos")
+ }, []Op{
+ xhostOp("chronos"),
+ }, stub.Expect{}},
+ })
+
+ checkOpIs(t, []opIsTestCase{
+ {"differs", xhostOp("kbd"), xhostOp("chronos"), false},
+ {"equals", xhostOp("chronos"), xhostOp("chronos"), true},
+ })
+
+ checkOpMeta(t, []opMetaTestCase{
+ {"xhost", xhostOp("chronos"), hst.EX11, "/tmp/.X11-unix", "SI:localuser:chronos"},
+ })
+}