diff options
Diffstat (limited to 'internal/system')
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, ®istry_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"}, + }) +} |
