diff options
| author | Ophestra <cat@gensokyo.uk> | 2025-11-15 13:47:09 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2025-11-15 13:58:34 +0900 |
| commit | a91920310d20e1fc472dea044fce8fd9438835df (patch) | |
| tree | 378cba47d8586e798130a2855f91a685e05244a3 /internal/dbus | |
| parent | 16e674782abbda8c85eac2e7d0c5c22e8cfbf5de (diff) | |
internal: relocate packages
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/dbus')
| -rw-r--r-- | internal/dbus/address.go | 193 | ||||
| -rw-r--r-- | internal/dbus/address_escape_test.go | 59 | ||||
| -rw-r--r-- | internal/dbus/address_test.go | 123 | ||||
| -rw-r--r-- | internal/dbus/config.go | 71 | ||||
| -rw-r--r-- | internal/dbus/config_test.go | 124 | ||||
| -rw-r--r-- | internal/dbus/dbus.go | 69 | ||||
| -rw-r--r-- | internal/dbus/dbus_test.go | 170 | ||||
| -rw-r--r-- | internal/dbus/export_test.go | 15 | ||||
| -rw-r--r-- | internal/dbus/proc.go | 188 | ||||
| -rw-r--r-- | internal/dbus/proc_test.go | 11 | ||||
| -rw-r--r-- | internal/dbus/proxy.go | 105 | ||||
| -rw-r--r-- | internal/dbus/samples_test.go | 214 | ||||
| -rw-r--r-- | internal/dbus/testdata/dev.vencord.Vesktop.json | 18 | ||||
| -rw-r--r-- | internal/dbus/testdata/org.chromium.Chromium+.json | 9 | ||||
| -rw-r--r-- | internal/dbus/testdata/org.chromium.Chromium.json | 24 | ||||
| -rw-r--r-- | internal/dbus/testdata/uk.gensokyo.CrashTestDummy.json | 21 |
16 files changed, 1414 insertions, 0 deletions
diff --git a/internal/dbus/address.go b/internal/dbus/address.go new file mode 100644 index 00000000..edee7e20 --- /dev/null +++ b/internal/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/dbus/address_escape_test.go b/internal/dbus/address_escape_test.go new file mode 100644 index 00000000..4ef48f8b --- /dev/null +++ b/internal/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/dbus/address_test.go b/internal/dbus/address_test.go new file mode 100644 index 00000000..7517aa1d --- /dev/null +++ b/internal/dbus/address_test.go @@ -0,0 +1,123 @@ +package dbus_test + +import ( + "errors" + "reflect" + "testing" + + "hakurei.app/internal/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/dbus/config.go b/internal/dbus/config.go new file mode 100644 index 00000000..2a09ee90 --- /dev/null +++ b/internal/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/dbus/config_test.go b/internal/dbus/config_test.go new file mode 100644 index 00000000..8509007a --- /dev/null +++ b/internal/dbus/config_test.go @@ -0,0 +1,124 @@ +package dbus_test + +import ( + "reflect" + "slices" + "strings" + "testing" + + "hakurei.app/hst" + "hakurei.app/internal/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/dbus/dbus.go b/internal/dbus/dbus.go new file mode 100644 index 00000000..9ea89f51 --- /dev/null +++ b/internal/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/dbus/dbus_test.go b/internal/dbus/dbus_test.go new file mode 100644 index 00000000..5d6e4c40 --- /dev/null +++ b/internal/dbus/dbus_test.go @@ -0,0 +1,170 @@ +package dbus_test + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "syscall" + "testing" + "time" + + "hakurei.app/internal/dbus" + "hakurei.app/internal/helper" + "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/dbus/export_test.go b/internal/dbus/export_test.go new file mode 100644 index 00000000..bc24f3f3 --- /dev/null +++ b/internal/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/dbus/proc.go b/internal/dbus/proc.go new file mode 100644 index 00000000..c934e89e --- /dev/null +++ b/internal/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.Resolve(ctx, p.msg, toolPath); 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/dbus/proc_test.go b/internal/dbus/proc_test.go new file mode 100644 index 00000000..544856ed --- /dev/null +++ b/internal/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/dbus/proxy.go b/internal/dbus/proxy.go new file mode 100644 index 00000000..9de49c41 --- /dev/null +++ b/internal/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/dbus/samples_test.go b/internal/dbus/samples_test.go new file mode 100644 index 00000000..7db3d6b9 --- /dev/null +++ b/internal/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/dbus/testdata/dev.vencord.Vesktop.json b/internal/dbus/testdata/dev.vencord.Vesktop.json new file mode 100644 index 00000000..05259774 --- /dev/null +++ b/internal/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/dbus/testdata/org.chromium.Chromium+.json b/internal/dbus/testdata/org.chromium.Chromium+.json new file mode 100644 index 00000000..0a27d719 --- /dev/null +++ b/internal/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/dbus/testdata/org.chromium.Chromium.json b/internal/dbus/testdata/org.chromium.Chromium.json new file mode 100644 index 00000000..be027f39 --- /dev/null +++ b/internal/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/dbus/testdata/uk.gensokyo.CrashTestDummy.json b/internal/dbus/testdata/uk.gensokyo.CrashTestDummy.json new file mode 100644 index 00000000..43272f67 --- /dev/null +++ b/internal/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 +} |
