diff --git a/package-lock.json b/package-lock.json index c9386af9d..5dfe5c6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,11 @@ "debug": "^4.3.6", "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", + "long": "^5.2.3", "node-persist": "^0.0.12", "source-map-support": "^0.5.21", - "tweetnacl": "^1.0.3" + "tweetnacl": "^1.0.3", + "xml2js": "^0.6.2" }, "devDependencies": { "@antfu/eslint-config": "^3.0.0", @@ -27,6 +29,7 @@ "@types/plist": "^3.0.5", "@types/semver": "^7.3.7", "@types/source-map-support": "^0.5.10", + "@types/xml2js": "^0.4.14", "@vitest/coverage-v8": "^2.0.5", "axios": "^1.7.5", "commander": "^12.1.0", @@ -1725,6 +1728,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", @@ -5258,6 +5271,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", diff --git a/package.json b/package.json index 94cdfb505..a34a8ce8c 100644 --- a/package.json +++ b/package.json @@ -61,9 +61,11 @@ "debug": "^4.3.6", "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", + "long": "^5.2.3", "node-persist": "^0.0.12", "source-map-support": "^0.5.21", - "tweetnacl": "^1.0.3" + "tweetnacl": "^1.0.3", + "xml2js": "^0.6.2" }, "devDependencies": { "@antfu/eslint-config": "^3.0.0", @@ -73,6 +75,7 @@ "@types/plist": "^3.0.5", "@types/semver": "^7.3.7", "@types/source-map-support": "^0.5.10", + "@types/xml2js": "^0.4.14", "@vitest/coverage-v8": "^2.0.5", "axios": "^1.7.5", "commander": "^12.1.0", diff --git a/src/lib/Advertiser.ts b/src/lib/Advertiser.ts index 08377f8f3..dbdb902bc 100644 --- a/src/lib/Advertiser.ts +++ b/src/lib/Advertiser.ts @@ -12,10 +12,10 @@ import { createHash } from 'node:crypto' import { EventEmitter } from 'node:events' import { getResponder, ServiceEvent, ServiceType } from '@homebridge/ciao' -import { systemBus } from '@homebridge/dbus-native' import bonjour from 'bonjour-hap' import createDebug from 'debug' +import { systemBus } from './dbus/index.js' import { PromiseTimeout } from './util/promise-utils.js' const debug = createDebug('HAP-NodeJS:Advertiser') diff --git a/src/lib/dbus/align.ts b/src/lib/dbus/align.ts new file mode 100644 index 000000000..e61342d49 --- /dev/null +++ b/src/lib/dbus/align.ts @@ -0,0 +1,12 @@ +import { Buffer } from 'safe-buffer' + +export default function align(ps: any, n: number) { + const pad = n - (ps._offset % n) + if (pad === 0 || pad === n) { + return + } + // TODO: write8(0) in a loop (3 to 7 times here) could be more efficient + const padBuff = Buffer.alloc(pad) + ps.put(Buffer.from(padBuff)) + ps._offset += pad +} diff --git a/src/lib/dbus/bus.ts b/src/lib/dbus/bus.ts new file mode 100644 index 000000000..a87bbfbcc --- /dev/null +++ b/src/lib/dbus/bus.ts @@ -0,0 +1,248 @@ +// @ts-nocheck +import { EventEmitter } from 'node:events' + +import constants from './constants.js' +import { introspectBus } from './introspect.js' +import stdDbusIfaces from './stdifaces.js' + +export default function MessageBus(conn, opts) { + if (!(this instanceof MessageBus)) { + return new MessageBus(conn) + } + if (!opts) { + opts = {} + } + + const self = this // eslint-disable-line ts/no-this-alias + this.connection = conn + this.serial = 1 + this.cookies = {} // TODO: rename to methodReturnHandlers + this.methodCallHandlers = {} + this.signals = new EventEmitter() + this.exportedObjects = {} + + this.invoke = function (msg, callback) { + if (!msg.type) { + msg.type = constants.messageType.methodCall + } + msg.serial = self.serial++ + this.cookies[msg.serial] = callback + self.connection.message(msg) + } + + this.invokeDbus = function (msg, callback) { + if (!msg.path) { + msg.path = '/org/freedesktop/DBus' + } + if (!msg.destination) { + msg.destination = 'org.freedesktop.DBus' + } + if (!msg.interface) { + msg.interface = 'org.freedesktop.DBus' + } + self.invoke(msg, callback) + } + + this.mangle = function (path, iface, member) { + const obj = {} + if (typeof path === 'object') { + // handle one argument case mangle(msg) + obj.path = path.path + obj.interface = path.interface + obj.member = path.member + } else { + obj.path = path + obj.interface = iface + obj.member = member + } + return JSON.stringify(obj) + } + + // Warning: errorName must respect the same rules as interface names (must contain a dot) + this.sendError = function (msg, errorName, errorText) { + const reply = { + type: constants.messageType.error, + serial: self.serial++, + replySerial: msg.serial, + destination: msg.sender, + errorName, + signature: 's', + body: [errorText], + } + this.connection.message(reply) + } + + // route reply/error + this.connection.on('message', (msg) => { + function invoke(impl, func, resultSignature) { + Promise.resolve() + .then(() => { + return func.apply(impl, (msg.body || []).concat(msg)) + }) + .then( + (methodReturnResult) => { + const methodReturnReply = { + type: constants.messageType.methodReturn, + serial: self.serial++, + destination: msg.sender, + replySerial: msg.serial, + } + if (methodReturnResult !== null) { + methodReturnReply.signature = resultSignature + methodReturnReply.body = [methodReturnResult] + } + self.connection.message(methodReturnReply) + }, + (e) => { + self.sendError( + msg, + e.dbusName || 'org.freedesktop.DBus.Error.Failed', + e.message || '', + ) + }, + ) + } + + let handler + if (msg.type === constants.messageType.methodReturn || msg.type === constants.messageType.error) { + handler = self.cookies[msg.replySerial] + if (handler) { + delete self.cookies[msg.replySerial] + const props = { + connection: self.connection, + bus: self, + message: msg, + signature: msg.signature, + } + let args = msg.body || [] + if (msg.type === constants.messageType.methodReturn) { + args = [null].concat(args) // first argument - no errors, null + handler.apply(props, args) // body as array of arguments + } else { + handler.call(props, { name: msg.errorName, message: args }) // body as first argument + } + } + } else if (msg.type === constants.messageType.signal) { + self.signals.emit(self.mangle(msg), msg.body, msg.signature) + } else { + // methodCall + if (stdDbusIfaces(msg, self)) { + return + } + + // exported interfaces handlers + const obj = self.exportedObjects[msg.path] + let iface + + if (obj) { + iface = obj[msg.interface] + if (iface) { + // now we are ready to serve msg.member + const impl = iface[1] + const func = impl[msg.member] + if (!func) { + self.sendError( + msg, + 'org.freedesktop.DBus.Error.UnknownMethod', + `Method "${msg.member}" on interface "${msg.interface}" doesn't exist`, + ) + return + } + // TODO safety check here + const resultSignature = iface[0].methods[msg.member][1] + invoke(impl, func, resultSignature) + return + } else { + console.error(`Interface ${msg.interface} is not supported`) + // TODO: respond with standard dbus error + } + } + + // setMethodCall handlers + handler = self.methodCallHandlers[self.mangle(msg)] + if (handler) { + invoke(null, handler[0], handler[1]) + } else { + self.sendError( + msg, + 'org.freedesktop.DBus.Error.UnknownService', + 'Uh oh oh', + ) + } + } + }) + + // register name + if (opts.direct !== true) { + this.invokeDbus({ member: 'Hello' }, (err, name) => { + if (err) { + throw new Error(err) + } + self.name = name + }) + } else { + self.name = null + } + + // eslint-disable-next-line unicorn/consistent-function-scoping + function DBusObject(name, service) { + this.name = name + this.service = service + this.as = function (name) { + return this.proxy[name] + } + } + + function DBusService(name, bus) { + this.name = name + this.bus = bus + this.getObject = function (name, callback) { + if (name === undefined) { + return callback(new Error('Object name is null or undefined')) + } + const obj = new DBusObject(name, this) + introspectBus(obj, (err, ifaces, nodes) => { + if (err) { + return callback(err) + } + obj.proxy = ifaces + obj.nodes = nodes + callback(null, obj) + }) + } + + this.getInterface = function (objName, ifaceName, callback) { + this.getObject(objName, (err, obj) => { + if (err) { + return callback(err) + } + callback(null, obj.as(ifaceName)) + }) + } + } + + this.getService = function (name) { + return new DBusService(name, this) + } + + this.getObject = function (path, name, callback) { + const service = this.getService(path) + return service.getObject(name, callback) + } + + this.getInterface = function (path, objname, name, callback) { + return this.getObject(path, objname, (err, obj) => { + if (err) { + return callback(err) + } + callback(null, obj.as(name)) + }) + } + + this.removeMatch = function (match, callback) { + this.invokeDbus( + { member: 'RemoveMatch', signature: 's', body: [match] }, + callback, + ) + } +} diff --git a/src/lib/dbus/constants.ts b/src/lib/dbus/constants.ts new file mode 100644 index 000000000..a641afc9d --- /dev/null +++ b/src/lib/dbus/constants.ts @@ -0,0 +1,54 @@ +export default { + messageType: { + invalid: 0, + methodCall: 1, + methodReturn: 2, + error: 3, + signal: 4, + }, + + headerTypeName: [ + null, + 'path', + 'interface', + 'member', + 'errorName', + 'replySerial', + 'destination', + 'sender', + 'signature', + ], + + // TODO: merge to single hash? e.g path -> [1, 'o'] + fieldSignature: { + path: 'o', + interface: 's', + member: 's', + errorName: 's', + replySerial: 'u', + destination: 's', + sender: 's', + signature: 'g', + }, + headerTypeId: { + path: 1, + interface: 2, + member: 3, + errorName: 4, + replySerial: 5, + destination: 6, + sender: 7, + signature: 8, + }, + protocolVersion: 1, + flags: { + noReplyExpected: 1, + noAutoStart: 2, + }, + endianness: { + le: 108, + be: 66, + }, + messageSignature: 'yyyyuua(yv)', + defaultAuthMethods: ['EXTERNAL', 'DBUS_COOKIE_SHA1', 'ANONYMOUS'], +} diff --git a/src/lib/dbus/dbus-buffer.ts b/src/lib/dbus/dbus-buffer.ts new file mode 100644 index 000000000..f020941ad --- /dev/null +++ b/src/lib/dbus/dbus-buffer.ts @@ -0,0 +1,189 @@ +// @ts-nocheck +import Long from 'long' + +import parseSignature from './signature.js' + +// Buffer + position + global start position ( used in alignment ) +export default function DBusBuffer(buffer, startPos, options) { + if (typeof options !== 'object') { + options = { ayBuffer: true, ReturnLongjs: false } + } else if (options.ayBuffer === undefined) { + // default settings object + options.ayBuffer = true // enforce truthy default props + } + this.options = options + this.buffer = buffer + this.startPos = startPos || 0 + this.pos = 0 +} + +DBusBuffer.prototype.align = function (power) { + const allbits = (1 << power) - 1 + const paddedOffset = ((this.pos + this.startPos + allbits) >> power) << power + this.pos = paddedOffset - this.startPos +} + +DBusBuffer.prototype.readInt8 = function () { + this.pos++ + return this.buffer[this.pos - 1] +} + +DBusBuffer.prototype.readSInt16 = function () { + this.align(1) + const res = this.buffer.readInt16LE(this.pos) + this.pos += 2 + return res +} + +DBusBuffer.prototype.readInt16 = function () { + this.align(1) + const res = this.buffer.readUInt16LE(this.pos) + this.pos += 2 + return res +} + +DBusBuffer.prototype.readSInt32 = function () { + this.align(2) + const res = this.buffer.readInt32LE(this.pos) + this.pos += 4 + return res +} + +DBusBuffer.prototype.readInt32 = function () { + this.align(2) + const res = this.buffer.readUInt32LE(this.pos) + this.pos += 4 + return res +} + +DBusBuffer.prototype.readDouble = function () { + this.align(3) + const res = this.buffer.readDoubleLE(this.pos) + this.pos += 8 + return res +} + +DBusBuffer.prototype.readString = function (len) { + if (len === 0) { + this.pos++ + return '' + } + const res = this.buffer.toString('utf8', this.pos, this.pos + len) + this.pos += len + 1 // dbus strings are always zero-terminated ('s' and 'g' types) + return res +} + +DBusBuffer.prototype.readTree = function readTree(tree) { + switch (tree.type) { + case '(': + case '{': + case 'r': + this.align(3) + return this.readStruct(tree.child) + case 'a': { + if (!tree.child || tree.child.length !== 1) { + throw new Error('Incorrect array element signature') + } + const arrayBlobLength = this.readInt32() + return this.readArray(tree.child[0], arrayBlobLength) + } + case 'v': + return this.readVariant() + default: + return this.readSimpleType(tree.type) + } +} + +DBusBuffer.prototype.read = function read(signature) { + const tree = parseSignature(signature) + return this.readStruct(tree) +} + +DBusBuffer.prototype.readVariant = function readVariant() { + const signature = this.readSimpleType('g') + const tree = parseSignature(signature) + return [tree, this.readStruct(tree)] +} + +DBusBuffer.prototype.readStruct = function readStruct(struct) { + const result = [] + for (let i = 0; i < struct.length; ++i) { + result.push(this.readTree(struct[i])) + } + return result +} + +DBusBuffer.prototype.readArray = function readArray(eleType, arrayBlobSize) { + const start = this.pos + + // special case: treat ay as Buffer + if (eleType.type === 'y' && this.options.ayBuffer) { + this.pos += arrayBlobSize + return this.buffer.slice(start, this.pos) + } + + // end of array is start of first element + array size + // we need to add 4 bytes if not on 8-byte boundary + // and array element needs 8 byte alignment + if (['x', 't', 'd', '{', '(', 'r'].includes(eleType.type)) { + this.align(3) + } + const end = this.pos + arrayBlobSize + const result = [] + while (this.pos < end) result.push(this.readTree(eleType)) + return result +} + +DBusBuffer.prototype.readSimpleType = function readSimpleType(t) { + let data, len, word0, word1 + switch (t) { + case 'y': + return this.readInt8() + case 'b': + // TODO: spec says that true is strictly 1 and false is strictly 0 + // shold we error (or warn?) when non 01 values? + return !!this.readInt32() + case 'n': + return this.readSInt16() + case 'q': + return this.readInt16() + case 'u': + return this.readInt32() + case 'i': + return this.readSInt32() + case 'g': + len = this.readInt8() + return this.readString(len) + case 's': + case 'o': + len = this.readInt32() + return this.readString(len) + // TODO: validate object path here + // if (t === 'o' && !isValidObjectPath(str)) + // throw new Error('string is not a valid object path')); + case 'x': + // signed + this.align(3) + word0 = this.readInt32() + word1 = this.readInt32() + data = Long.fromBits(word0, word1, false) + if (this.options.ReturnLongjs) { + return data + } + return data.toNumber() // convert to number (good up to 53 bits) + case 't': + // unsigned + this.align(3) + word0 = this.readInt32() + word1 = this.readInt32() + data = Long.fromBits(word0, word1, true) + if (this.options.ReturnLongjs) { + return data + } + return data.toNumber() // convert to number (good up to 53 bits) + case 'd': + return this.readDouble() + default: + throw new Error(`Unsupported type: ${t}`) + } +} diff --git a/src/lib/dbus/handshake.ts b/src/lib/dbus/handshake.ts new file mode 100644 index 000000000..02c84be3d --- /dev/null +++ b/src/lib/dbus/handshake.ts @@ -0,0 +1,148 @@ +// @ts-nocheck +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { Buffer } from 'safe-buffer' + +import constants from './constants.js' +import readLine from './readline.js' + +function sha1(input) { + const shasum = crypto.createHash('sha1') + shasum.update(input) + return shasum.digest('hex') +} + +function getUserHome() { + return process.env[process.platform.match(/\$win/) ? 'USERPROFILE' : 'HOME'] +} + +function getCookie(context, id, cb) { + // http://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-sha + const dirname = path.join(getUserHome(), '.dbus-keyrings') + // > There is a default context, "org_freedesktop_general" that's used by servers that do not specify otherwise. + if (context.length === 0) { + context = 'org_freedesktop_general' + } + + const filename = path.join(dirname, context) + // check it's not writable by others and readable by user + fs.stat(dirname, (err, stat) => { + if (err) { + return cb(err) + } + if (stat.mode & 0o22) { + return cb( + new Error( + 'User keyrings directory is writeable by other users. Aborting authentication', + ), + ) + } + // eslint-disable-next-line no-prototype-builtins + if (process.hasOwnProperty('getuid') && stat.uid !== process.getuid()) { + return cb( + new Error( + 'Keyrings directory is not owned by the current user. Aborting authentication!', + ), + ) + } + fs.readFile(filename, 'ascii', (err, keyrings) => { + if (err) { + return cb(err) + } + const lines = keyrings.split('\n') + for (let l = 0; l < lines.length; ++l) { + const data = lines[l].split(' ') + if (id === data[0]) { + return cb(null, data[2]) + } + } + return cb(new Error('cookie not found')) + }) + }) +} + +function hexlify(input) { + return Buffer.from(input.toString(), 'ascii').toString('hex') +} + +export default function auth(stream, opts, cb) { + // filter used to make a copy so we don't accidentally change opts data + let authMethods + if (opts.authMethods) { + authMethods = opts.authMethods + } else { + authMethods = constants.defaultAuthMethods + } + stream.write('\0') + tryAuth(stream, authMethods.slice(), cb) +} + +function tryAuth(stream, methods, cb) { + if (methods.length === 0) { + return cb(new Error('No authentication methods left to try')) + } + + const authMethod = methods.shift() + // eslint-disable-next-line no-prototype-builtins + const uid = process.hasOwnProperty('getuid') ? process.getuid() : 0 + const id = hexlify(uid) + + function beginOrNextAuth() { + readLine(stream, (line) => { + const ok = line.toString('ascii').match(/^([A-Z]+) (.*)/i) + if (ok && ok[1] === 'OK') { + stream.write('BEGIN\r\n') + return cb(null, ok[2]) // ok[2] = guid. Do we need it? + } else { + // TODO: parse error! + if (!methods.empty) { + tryAuth(stream, methods, cb) + } else { + return cb(line) + } + } + }) + } + + switch (authMethod) { + case 'EXTERNAL': + stream.write(`AUTH ${authMethod} ${id}\r\n`) + beginOrNextAuth() + break + case 'DBUS_COOKIE_SHA1': + stream.write(`AUTH ${authMethod} ${id}\r\n`) + readLine(stream, (line) => { + const data = Buffer.from(line.toString().split(' ')[1].trim(), 'hex') + .toString() + .split(' ') + const cookieContext = data[0] + const cookieId = data[1] + const serverChallenge = data[2] + // any random 16 bytes should work, sha1(rnd) to make it simpler + const clientChallenge = crypto.randomBytes(16).toString('hex') + getCookie(cookieContext, cookieId, (err, cookie) => { + if (err) { + return cb(err) + } + const response = sha1( + [serverChallenge, clientChallenge, cookie].join(':'), + ) + const reply = hexlify(clientChallenge + response) + stream.write(`DATA ${reply}\r\n`) + beginOrNextAuth() + }) + }) + break + case 'ANONYMOUS': + stream.write('AUTH ANONYMOUS \r\n') + beginOrNextAuth() + break + default: + console.error(`Unsupported auth method: ${authMethod}`) + beginOrNextAuth() + break + } +} diff --git a/src/lib/dbus/index.ts b/src/lib/dbus/index.ts new file mode 100644 index 000000000..078c7a088 --- /dev/null +++ b/src/lib/dbus/index.ts @@ -0,0 +1,143 @@ +// @ts-nocheck +import { EventEmitter } from 'node:events' +import net from 'node:net' +import process from 'node:process' + +import MessageBus from './bus.js' +import clientHandshake from './handshake.js' +import { marshalMessage, unmarshalMessages } from './message.js' + +function createStream(opts) { + if (opts.stream) { + return opts.stream + } + let host = opts.host + let port = opts.port + const socket = opts.socket + if (socket) { + return net.createConnection(socket) + } + if (port) { + return net.createConnection(port, host) + } + + const busAddress = opts.busAddress || process.env.DBUS_SESSION_BUS_ADDRESS + if (!busAddress) { + throw new Error('unknown bus address') + } + + const addresses = busAddress.split(';') + for (let i = 0; i < addresses.length; ++i) { + const address = addresses[i] + const familyParams = address.split(':') + const family = familyParams[0] + const params = {} + familyParams[1].split(',').map((p) => { // eslint-disable-line array-callback-return + const keyVal = p.split('=') + params[keyVal[0]] = keyVal[1] + }) + + try { + switch (family.toLowerCase()) { + case 'tcp': + host = params.host || 'localhost' + port = params.port + return net.createConnection(port, host) + case 'unix': + if (params.socket) { + return net.createConnection(params.socket) + } + if (params.path) { + return net.createConnection(params.path) + } + throw new Error( + 'not enough parameters for \'unix\' connection - you need to specify \'socket\' or \'abstract\' or \'path\' parameter', + ) + default: + throw new Error(`unknown address type:${family}`) + } + } catch (e) { + if (i < addresses.length - 1) { + console.warn(e.message) + } else { + throw e + } + } + } +} + +function createConnection(opts) { + const self = new EventEmitter() + if (!opts) { + opts = {} + } + const stream = (self.stream = createStream(opts)) + stream.setNoDelay() + + stream.on('error', (err) => { + // forward network and stream errors + self.emit('error', err) + }) + + stream.on('end', () => { + self.emit('end') + self.message = function () { + console.warn('Didn\'t write bytes to closed stream') + } + }) + + self.end = function () { + stream.end() + return self + } + + clientHandshake(stream, opts, (error, guid) => { + if (error) { + return self.emit('error', error) + } + self.guid = guid + self.emit('connect') + unmarshalMessages( + stream, + (message) => { + self.emit('message', message) + }, + opts, + ) + }) + + self._messages = [] + + // pre-connect version, buffers all messages. replaced after connect + self.message = function (msg) { + self._messages.push(msg) + } + + self.once('connect', () => { + self.state = 'connected' + for (let i = 0; i < self._messages.length; ++i) { + stream.write(marshalMessage(self._messages[i])) + } + self._messages.length = 0 + + // no need to buffer once connected + self.message = function (msg) { + stream.write(marshalMessage(msg)) + } + }) + + return self +} + +function createClient(params) { + const connection = createConnection(params || {}) + return new MessageBus(connection, params || {}) +} + +export function systemBus() { + return createClient({ + busAddress: + process.env.DBUS_SYSTEM_BUS_ADDRESS + || 'unix:path=/var/run/dbus/system_bus_socket', + }) +} diff --git a/src/lib/dbus/introspect.ts b/src/lib/dbus/introspect.ts new file mode 100644 index 000000000..498edaef3 --- /dev/null +++ b/src/lib/dbus/introspect.ts @@ -0,0 +1,204 @@ +// @ts-nocheck +import xml2js from 'xml2js' + +export function introspectBus(obj, callback) { + const bus = obj.service.bus + bus.invoke( + { + destination: obj.service.name, + path: obj.name, + interface: 'org.freedesktop.DBus.Introspectable', + member: 'Introspect', + }, + (err, xml) => { + processXML(err, xml, obj, callback) + }, + ) +} + +export function processXML(err, xml, obj, callback) { + if (err) { + return callback(err) + } + const parser = new xml2js.Parser() + parser.parseString(xml, (err, result) => { + if (err) { + return callback(err) + } + if (!result.node) { + throw new Error('No root XML node') + } + result = result.node // unwrap the root node + // If no interface, try first sub node? + if (!result.interface) { + if (result.node && result.node.length > 0 && result.node[0].$) { + const subObj = Object.assign(obj, {}) + if (subObj.name.slice(-1) !== '/') { + subObj.name += '/' + } + subObj.name += result.node[0].$.name + return module.exports.introspectBus(subObj, callback) + } + return callback(new Error('No such interface found')) + } + const proxy = {} + const nodes = [] + let ifaceName, method, property, iface, arg, signature, currentIface + const ifaces = result.interface + const xmlnodes = result.node || [] + + for (let n = 1; n < xmlnodes.length; ++n) { + // Start at 1 because we want to skip the root node + nodes.push(xmlnodes[n].$.name) + } + + for (let i = 0; i < ifaces.length; ++i) { + iface = ifaces[i] + ifaceName = iface.$.name + currentIface = proxy[ifaceName] = new DBusInterface(obj, ifaceName) + + for (let m = 0; iface.method && m < iface.method.length; ++m) { + method = iface.method[m] + signature = '' + const methodName = method.$.name + for (let a = 0; method.arg && a < method.arg.length; ++a) { + arg = method.arg[a].$ + if (arg.direction === 'in') { + signature += arg.type + } + } + // add method + currentIface.$createMethod(methodName, signature) + } + for (let p = 0; iface.property && p < iface.property.length; ++p) { + property = iface.property[p] + currentIface.$createProp( + property.$.name, + property.$.type, + property.$.access, + ) + } + // TODO: introspect signals + } + callback(null, proxy, nodes) + }) +} + +function DBusInterface(parent_obj, ifname) { + // Since methods and props presently get added directly to the object, to avoid collision with existing names we must use $ naming convention as $ is invalid for dbus member names + // https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names + this.$parent = parent_obj // parent DbusObject + this.$name = ifname // string interface name +} + +DBusInterface.prototype.$getSigHandler = function (callback) { + let index = this.$callbacks.indexOf(callback) + if (index === -1) { + index = this.$callbacks.push(callback) - 1 + this.$sigHandlers[index] = function (messageBody) { + callback(...messageBody) + } + } + return this.$sigHandlers[index] +} + +DBusInterface.prototype.removeListener = DBusInterface.prototype.off = function (signame, callback) { + const bus = this.$parent.service.bus + const signalFullName = bus.mangle(this.$parent.name, this.$name, signame) + bus.signals.removeListener(signalFullName, this.$getSigHandler(callback)) + if (!bus.signals.listeners(signalFullName).length) { + // There is no event handlers for this match + const match = getMatchRule(this.$parent.name, this.$name, signame) + bus.removeMatch( + match, + (err) => { + if (err) { + throw new Error(err) + } + // Now it is safe to empty these arrays + this.$callbacks.length = 0 + this.$sigHandlers.length = 0 + }, + ) + } +} + +DBusInterface.prototype.$createMethod = function (mName, signature) { + this.$methods[mName] = signature + this[mName] = function (...args) { + this.$callMethod(mName, args) + } +} + +DBusInterface.prototype.$callMethod = function (mName, args) { + const bus = this.$parent.service.bus + if (!Array.isArray(args)) { + args = Array.from(args) // Array.prototype.slice.apply(args) + } + const callback + = typeof args[args.length - 1] === 'function' ? args.pop() : function () {} + const msg = { + destination: this.$parent.service.name, + path: this.$parent.name, + interface: this.$name, + member: mName, + } + if (this.$methods[mName] !== '') { + msg.signature = this.$methods[mName] + msg.body = args + } + bus.invoke(msg, callback) +} + +DBusInterface.prototype.$createProp = function (propName, propType, propAccess) { + this.$properties[propName] = { type: propType, access: propAccess } + Object.defineProperty(this, propName, { + enumerable: true, + get: () => callback => this.$readProp(propName, callback), // eslint-disable-line unicorn/consistent-function-scoping + set(val) { + this.$writeProp(propName, val) + }, + }) +} + +DBusInterface.prototype.$readProp = function (propName, callback) { + const bus = this.$parent.service.bus + bus.invoke( + { + destination: this.$parent.service.name, + path: this.$parent.name, + interface: 'org.freedesktop.DBus.Properties', + member: 'Get', + signature: 'ss', + body: [this.$name, propName], + }, + (err, val) => { + if (err) { + callback(err) + } else { + const signature = val[0] + if (signature.length === 1) { + callback(err, val[1][0]) + } else { + callback(err, val[1]) + } + } + }, + ) +} + +DBusInterface.prototype.$writeProp = function (propName, val) { + const bus = this.$parent.service.bus + bus.invoke({ + destination: this.$parent.service.name, + path: this.$parent.name, + interface: 'org.freedesktop.DBus.Properties', + member: 'Set', + signature: 'ssv', + body: [this.$name, propName, [this.$properties[propName].type, val]], + }) +} + +function getMatchRule(objName, ifName, signame) { + return `type='signal',path='${objName}',interface='${ifName}',member='${signame}'` +} diff --git a/src/lib/dbus/marshall.ts b/src/lib/dbus/marshall.ts new file mode 100644 index 000000000..5e4780570 --- /dev/null +++ b/src/lib/dbus/marshall.ts @@ -0,0 +1,110 @@ +// @ts-nocheck +import assert from 'node:assert' +import { Buffer } from 'node:buffer' + +import align from './align.js' +import MakeSimpleMarshaller from './marshallers.js' +import put from './put.js' +import parseSignature from './signature.js' + +export default function marshall(signature, data, offset = 0) { + const tree = parseSignature(signature) + if (!Array.isArray(data) || data.length !== tree.length) { + throw new Error( + `message body does not match message signature. Body:${JSON.stringify( + data, + )}, signature:${signature}`, + ) + } + const putstream = put() + putstream._offset = offset + const buf = writeStruct(putstream, tree, data).buffer() + return buf +} + +function writeStruct(ps, tree, data) { + if (tree.length !== data.length) { + throw new Error('Invalid struct data') + } + for (let i = 0; i < tree.length; ++i) { + write(ps, tree[i], data[i]) + } + return ps +} + +function write(ps, ele, data) { + switch (ele.type) { + case '(': + case '{': + align(ps, 8) + writeStruct(ps, ele.child, data) + break + case 'a': { + // array serialisation: + // length of array body aligned at 4 byte boundary + // (optional 4 bytes to align first body element on 8-byte boundary if element + // body + const arrPut = put() + arrPut._offset = ps._offset + const _offset = arrPut._offset + writeSimple(arrPut, 'u', 0) // array length placeholder + const lengthOffset = arrPut._offset - 4 - _offset + // we need to align here because alignment is not included in array length + if (['x', 't', 'd', '{', '('].includes(ele.child[0].type)) { + align(arrPut, 8) + } + const startOffset = arrPut._offset + for (let i = 0; i < data.length; ++i) { + write(arrPut, ele.child[0], data[i]) + } + const arrBuff = arrPut.buffer() + const length = arrPut._offset - startOffset + // lengthOffset in the range 0 to 3 depending on number of align bytes padded _before_ arrayLength + arrBuff.writeUInt32LE(length, lengthOffset) + ps.put(arrBuff) + ps._offset += arrBuff.length + break + } + case 'v': { + // TODO: allow serialisation of simple types as variants, e. g 123 -> ['u', 123], true -> ['b', 1], 'abc' -> ['s', 'abc'] + assert.equal(data.length, 2, 'variant data should be [signature, data]') + const signatureEle = { + type: 'g', + child: [], + } + write(ps, signatureEle, data[0]) + const tree = parseSignature(data[0]) + assert(tree.length === 1) + write(ps, tree[0], data[1]) + break + } + default: + return writeSimple(ps, ele.type, data) + } +} + +const stringTypes = ['g', 'o', 's'] + +function writeSimple(ps, type, data) { + if (typeof data === 'undefined') { + throw new TypeError('Serialisation of JS \'undefined\' type is not supported by d-bus') + } + if (data === null) { + throw new Error('Serialisation of null value is not supported by d-bus') + } + + if (Buffer.isBuffer(data)) { + data = data.toString()// encoding? + } + if (stringTypes.includes(type) && typeof data !== 'string') { + throw new Error( + `Expected string or buffer argument, got ${JSON.stringify( + data, + )} of type '${type}'`, + ) + } + + const simpleMarshaller = MakeSimpleMarshaller(type) + simpleMarshaller.marshall(ps, data) + return ps +} diff --git a/src/lib/dbus/marshallers.ts b/src/lib/dbus/marshallers.ts new file mode 100644 index 000000000..79f3aca0a --- /dev/null +++ b/src/lib/dbus/marshallers.ts @@ -0,0 +1,341 @@ +// @ts-nocheck +import Long from 'long' +import { Buffer } from 'safe-buffer' + +import align from './align.js' +import parseSignature from './signature.js' + +function checkRange(minValue, maxValue, data) { + if (data > maxValue || data < minValue) { + throw new Error('Number outside range') + } +} + +function checkInteger(data) { + if (typeof data !== 'number') { + throw new TypeError(`Data: ${data} was not of type number`) + } + if (Math.floor(data) !== data) { + throw new Error(`Data: ${data} was not an integer`) + } +} + +function checkBoolean(data) { + if (!(typeof data === 'boolean' || data === 0 || data === 1)) { + throw new Error(`Data: ${data} was not of type boolean`) + } +} + +// This is essentially a tweaked version of 'fromValue' from Long.js with error checking. +// This can take number or string of decimal characters or 'Long' instance (or Long-style object with props low,high,unsigned). +function makeLong(val, signed) { + if (val instanceof Long) { + return val + } + if (val instanceof Number) { + val = val.valueOf() + } + if (typeof val === 'number') { + try { + // Long.js won't alert you to precision loss in passing more than 53 bit int through a double number, so we check here + checkInteger(val) + if (signed) { + checkRange(-0x1FFFFFFFFFFFFF, 0x1FFFFFFFFFFFFF, val) + } else { + checkRange(0, 0x1FFFFFFFFFFFFF, val) + } + } catch (e) { + e.message += ' (Number type can only carry 53 bit integer)' + throw e + } + try { + return Long.fromNumber(val, !signed) + } catch (e) { + e.message = `Error converting number to 64bit integer "${e.message}"` + throw e + } + } + if (typeof val === 'string' || val instanceof String) { + let radix = 10 + val = val.trim().toUpperCase() // remove extra whitespace and make uppercase (for hex) + if (val.substring(0, 2) === '0X') { + radix = 16 + val = val.substring(2) + } else if (val.substring(0, 3) === '-0X') { + // unusual, but just in case? + radix = 16 + val = `-${val.substring(3)}` + } + val = val.replace(/^0+(?=\d)/, '') // dump leading zeroes + let data + try { + data = Long.fromString(val, !signed, radix) + } catch (e) { + e.message = `Error converting string to 64bit integer '${e.message}'` + throw e + } + // If string represents a number outside 64 bit range, it can quietly overflow. + // We assume if things converted correctly the string coming out of Long should match what went into it. + if (data.toString(radix).toUpperCase() !== val) { + throw new Error( + `Data: '${val}' did not convert correctly to ${ + signed ? 'signed' : 'unsigned' + } 64 bit`, + ) + } + return data + } + // Throws for non-objects, converts non-instanceof Long: + try { + return Long.fromBits(val.low, val.high, val.unsigned) + } catch (e) { + e.message = `Error converting object to 64bit integer '${e.message}'` + throw e + } +} + +function checkLong(data, signed) { + if (!Long.isLong(data)) { + data = makeLong(data, signed) + } + + // Do we enforce that Long.js object unsigned/signed match the field even if it is still in range? + // Probably, might help users avoid unintended bugs? + if (signed) { + if (data.unsigned) { + throw new Error( + 'Longjs object is unsigned, but marshalling into signed 64 bit field', + ) + } + if (data.gt(Long.MAX_VALUE) || data.lt(Long.MIN_VALUE)) { + throw new Error(`Data: ${data} was out of range (64-bit signed)`) + } + } else { + if (!data.unsigned) { + throw new Error( + 'Longjs object is signed, but marshalling into unsigned 64 bit field', + ) + } + // NOTE: data.gt(Long.MAX_UNSIGNED_VALUE) will catch if Long.js object is a signed value but is still within unsigned range! + // Since we are enforcing signed type matching between Long.js object and field, this note should not matter. + if (data.gt(Long.MAX_UNSIGNED_VALUE) || data.lt(0)) { + throw new Error(`Data: ${data} was out of range (64-bit unsigned)`) + } + } + return data +} + +function checkValidSignature(data) { + if (data.length > 0xFF) { + throw new Error( + `Data: ${data} is too long for signature type (${data.length} > 255)`, + ) + } + + let parenCount = 0 + for (let ii = 0; ii < data.length; ++ii) { + if (parenCount > 32) { + throw new Error( + `Maximum container type nesting exceeded in signature type:${data}`, + ) + } + switch (data[ii]) { + case '(': + ++parenCount + break + case ')': + --parenCount + break + default: + /* no-op */ + break + } + } + parseSignature(data) +} + +function checkValidString(data) { + if (typeof data !== 'string') { + throw new TypeError(`Data: ${data} was not of type string`) + } else if (data.includes('\0')) { + throw new Error('String contains null byte') + } +} + +/** + * MakeSimpleMarshaller + * @param signature - the signature of the data you want to check + * @returns a simple marshaller with the "check" method + * + * check returns nothing - it only raises errors if the data is + * invalid for the signature + */ +export default function MakeSimpleMarshaller(signature) { + const marshaller = {} + + switch (signature) { + case 'o': + // object path + // TODO: verify object path here? + case 's': // eslint-disable-line no-fallthrough + // STRING + marshaller.check = function (data) { + checkValidString(data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + // utf8 string + align(ps, 4) + const buff = Buffer.from(data, 'utf8') + ps.word32le(buff.length).put(buff).word8(0) + ps._offset += 5 + buff.length + } + break + case 'g': + // SIGNATURE + marshaller.check = function (data) { + checkValidString(data) + checkValidSignature(data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + // signature + const buff = Buffer.from(data, 'ascii') + ps.word8(data.length).put(buff).word8(0) + ps._offset += 2 + buff.length + } + break + case 'y': + // BYTE + marshaller.check = function (data) { + checkInteger(data) + checkRange(0x00, 0xFF, data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + ps.word8(data) + ps._offset++ + } + break + case 'b': + // BOOLEAN + marshaller.check = function (data) { + checkBoolean(data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + // booleans serialised as 0/1 unsigned 32 bit int + data = data ? 1 : 0 + align(ps, 4) + ps.word32le(data) + ps._offset += 4 + } + break + case 'n': + // INT16 + marshaller.check = function (data) { + checkInteger(data) + checkRange(-0x7FFF - 1, 0x7FFF, data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + align(ps, 2) + const buff = Buffer.alloc(2) + buff.writeInt16LE(Number.parseInt(data), 0) + ps.put(buff) + ps._offset += 2 + } + break + case 'q': + // UINT16 + marshaller.check = function (data) { + checkInteger(data) + checkRange(0, 0xFFFF, data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + align(ps, 2) + ps.word16le(data) + ps._offset += 2 + } + break + case 'i': + // INT32 + marshaller.check = function (data) { + checkInteger(data) + checkRange(-0x7FFFFFFF - 1, 0x7FFFFFFF, data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + align(ps, 4) + const buff = Buffer.alloc(4) + buff.writeInt32LE(Number.parseInt(data), 0) + ps.put(buff) + ps._offset += 4 + } + break + case 'u': + // UINT32 + marshaller.check = function (data) { + checkInteger(data) + checkRange(0, 0xFFFFFFFF, data) + } + marshaller.marshall = function (ps, data) { + this.check(data) + // 32 t unsigned int + align(ps, 4) + ps.word32le(data) + ps._offset += 4 + } + break + case 't': + // UINT64 + marshaller.check = function (data) { + return checkLong(data, false) + } + marshaller.marshall = function (ps, data) { + data = this.check(data) + align(ps, 8) + ps.word32le(data.low) + ps.word32le(data.high) + ps._offset += 8 + } + break + case 'x': + // INT64 + marshaller.check = function (data) { + return checkLong(data, true) + } + marshaller.marshall = function (ps, data) { + data = this.check(data) + align(ps, 8) + ps.word32le(data.low) + ps.word32le(data.high) + ps._offset += 8 + } + break + case 'd': + // DOUBLE + marshaller.check = function (data) { + if (typeof data !== 'number') { + throw new TypeError(`Data: ${data} was not of type number`) + } else if (Number.isNaN(data)) { + throw new TypeError(`Data: ${data} was not a number`) + } else if (!Number.isFinite(data)) { + throw new TypeError('Number outside range') + } + } + marshaller.marshall = function (ps, data) { + this.check(data) + align(ps, 8) + const buff = Buffer.alloc(8) + buff.writeDoubleLE(Number.parseFloat(data), 0) + ps.put(buff) + ps._offset += 8 + } + break + default: + throw new Error(`Unknown data type format: ${signature}`) + } + return marshaller +} diff --git a/src/lib/dbus/message.ts b/src/lib/dbus/message.ts new file mode 100644 index 000000000..9df188314 --- /dev/null +++ b/src/lib/dbus/message.ts @@ -0,0 +1,125 @@ +// @ts-nocheck +import { Buffer } from 'safe-buffer' + +import constants from './constants.js' +import DBusBuffer from './dbus-buffer.js' +import marshall from './marshall.js' + +const headerSignature = [ + { + type: 'a', + child: [ + { + type: '(', + child: [ + { + type: 'y', + child: [], + }, + { + type: 'v', + child: [], + }, + ], + }, + ], + }, +] + +export function unmarshalMessages(stream, onMessage, opts) { + let state = 0 // 0: header, 1: fields + body + let header, fieldsAndBody + let fieldsLength, fieldsLengthPadded + let fieldsAndBodyLength = 0 + let bodyLength = 0 + stream.on('readable', () => { + while (1) { + if (state === 0) { + header = stream.read(16) + if (!header) { + break + } + state = 1 + + fieldsLength = header.readUInt32LE(12) + fieldsLengthPadded = ((fieldsLength + 7) >> 3) << 3 + bodyLength = header.readUInt32LE(4) + fieldsAndBodyLength = fieldsLengthPadded + bodyLength + } else { + fieldsAndBody = stream.read(fieldsAndBodyLength) + if (!fieldsAndBody) { + break + } + state = 0 + + const messageBuffer = new DBusBuffer(fieldsAndBody, undefined, opts) + const unmarshalledHeader = messageBuffer.readArray( + headerSignature[0].child[0], + fieldsLength, + ) + messageBuffer.align(3) + let headerName + const message = {} + message.serial = header.readUInt32LE(8) + + for (let i = 0; i < unmarshalledHeader.length; ++i) { + headerName = constants.headerTypeName[unmarshalledHeader[i][0]] + message[headerName] = unmarshalledHeader[i][1][1][0] + } + + message.type = header[1] + message.flags = header[2] + + if (bodyLength > 0 && message.signature) { + message.body = messageBuffer.read(message.signature) + } + onMessage(message) + } + } + }) +} + +export function marshalMessage(message) { + if (!message.serial) { + throw new Error('Missing or invalid serial') + } + const flags = message.flags || 0 + const type = message.type || constants.messageType.methodCall + let bodyLength = 0 + let bodyBuff + if (message.signature && message.body) { + bodyBuff = marshall(message.signature, message.body) + bodyLength = bodyBuff.length + } + const header = [ + constants.endianness.le, + type, + flags, + constants.protocolVersion, + bodyLength, + message.serial, + ] + const headerBuff = marshall('yyyyuu', header) + const fields = [] + constants.headerTypeName.forEach((fieldName) => { + const fieldVal = message[fieldName] + if (fieldVal) { + fields.push([ + constants.headerTypeId[fieldName], + [constants.fieldSignature[fieldName], fieldVal], + ]) + } + }) + const fieldsBuff = marshall('a(yv)', [fields], 12) + const headerLenAligned + = ((headerBuff.length + fieldsBuff.length + 7) >> 3) << 3 + const messageLen = headerLenAligned + bodyLength + const messageBuff = Buffer.alloc(messageLen) + headerBuff.copy(messageBuff) + fieldsBuff.copy(messageBuff, headerBuff.length) + if (bodyLength > 0) { + bodyBuff.copy(messageBuff, headerLenAligned) + } + + return messageBuff +} diff --git a/src/lib/dbus/put.ts b/src/lib/dbus/put.ts new file mode 100644 index 000000000..59f36db37 --- /dev/null +++ b/src/lib/dbus/put.ts @@ -0,0 +1,94 @@ +// @ts-nocheck +import assert from 'node:assert' +import { Buffer } from 'node:buffer' + +export default function Put() { + if (!(this instanceof Put)) { + return new Put() + } + + const words = [] + let len = 0 + + this.put = function (buf) { + words.push({ buffer: buf }) + len += buf.length + return this + } + + this.word8 = function (x) { + words.push({ bytes: 1, value: x }) + len += 1 + return this + } + + [8, 16, 24, 32, 64].forEach((bits) => { + this[`word${bits}be`] = function (x) { + words.push({ endian: 'big', bytes: bits / 8, value: x }) + len += bits / 8 + return this + } + + this[`word${bits}le`] = function (x) { + words.push({ endian: 'little', bytes: bits / 8, value: x }) + len += bits / 8 + return this + } + }) + + this.pad = function (bytes) { + assert(Number.isInteger(bytes), 'pad(bytes) must be supplied with an integer!') + words.push({ endian: 'big', bytes, value: 0 }) + len += bytes + return this + } + + this.length = function () { + return len + } + + this.buffer = function () { + const buf = Buffer.alloc(len) + let offset = 0 + words.forEach((word) => { + if (word.buffer) { + word.buffer.copy(buf, offset, 0) + offset += word.buffer.length + } else if (word.bytes === 'float') { + // s * f * 2^e + const v = Math.abs(word.value) + const s = (word.value >= 0) * 1 + const e = Math.ceil(Math.log(v) / Math.LN2) + const f = v / (1 << e) + + // s:1, e:7, f:23 + // [seeeeeee][efffffff][ffffffff][ffffffff] + buf[offset++] = (s << 7) & ~~(e / 2) + buf[offset++] = ((e & 1) << 7) & ~~(f / (1 << 16)) + buf[offset++] = 0 + buf[offset++] = 0 + offset += 4 + } else { + const big = word.endian === 'big' + const ix = big ? [(word.bytes - 1) * 8, -8] : [0, 8] + + for ( + let i = ix[0]; + big ? i >= 0 : i < word.bytes * 8; + i += ix[1] + ) { + if (i >= 32) { + buf[offset++] = Math.floor(word.value / 2 ** i) & 0xFF + } else { + buf[offset++] = (word.value >> i) & 0xFF + } + } + } + }) + return buf + } + + this.write = function (stream) { + stream.write(this.buffer()) + } +} diff --git a/src/lib/dbus/readline.ts b/src/lib/dbus/readline.ts new file mode 100644 index 000000000..36c7a71e6 --- /dev/null +++ b/src/lib/dbus/readline.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import { Buffer } from 'safe-buffer' + +export default function readOneLine(stream, cb) { + const bytes: any[] = [] + function readable() { + while (1) { + const buf = stream.read(1) + if (!buf) { + return + } + const b = buf[0] + if (b === 0x0A) { + try { + cb(Buffer.from(bytes)) + } catch (error) { + stream.emit('error', error) + } + stream.removeListener('readable', readable) + return + } + bytes.push(b) + } + } + stream.on('readable', readable) +} diff --git a/src/lib/dbus/signature.ts b/src/lib/dbus/signature.ts new file mode 100644 index 000000000..6cd8b3649 --- /dev/null +++ b/src/lib/dbus/signature.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +const match = { + '{': '}', + '(': ')', +} + +const knownTypes = {} +'(){}ybnqiuxtdsogarvehm*?@&^'.split('').forEach((c) => { + knownTypes[c] = true +}) + +function checkNotEnd(c) { + if (!c) { + throw new Error('Bad signature: unexpected end') + } + return c +} + +export default function parseSignature(signature) { + let index = 0 + function next() { + if (index < signature.length) { + const c = signature[index] + ++index + return c + } + return null + } + + function parseOne(c) { + if (!knownTypes[c]) { + throw new Error(`Unknown type: "${c}" in signature "${signature}"`) + } + + let ele + const res = { type: c, child: [] } + switch (c) { + case 'a': // array + ele = next() + checkNotEnd(ele) + res.child.push(parseOne(ele)) + return res + case '{': // dict entry + case '(': // struct + ele = next() + while (ele !== null && ele !== match[c]) { + res.child.push(parseOne(ele)) + ele = next() + } + checkNotEnd(ele) + return res + } + return res + } + + const ret = [] + let c = next() + while (c !== null) { + ret.push(parseOne(c)) + c = next() + } + return ret +} diff --git a/src/lib/dbus/stdifaces.ts b/src/lib/dbus/stdifaces.ts new file mode 100644 index 000000000..52673fc39 --- /dev/null +++ b/src/lib/dbus/stdifaces.ts @@ -0,0 +1,212 @@ +// @ts-nocheck +import constants from './constants.js' +import parseSignature from './signature.js' + +// TODO: use xmlbuilder + +const xmlHeader + = '' +let stdIfaces + +export default function (msg, bus) { + if ( + msg.interface === 'org.freedesktop.DBus.Introspectable' + && msg.member === 'Introspect' + ) { + if (msg.path === '/') { + msg.path = '' + } + + const resultXml = [xmlHeader] + const nodes = {} + // TODO: this is not very efficient for large number of exported objects + // need to build objects tree as they are exported and walk this tree on introspect request + for (const path in bus.exportedObjects) { + if (path.indexOf(msg.path) === 0) { + // objects path starts with requested + const introspectableObj = bus.exportedObjects[msg.path] + if (introspectableObj) { + nodes[msg.path] = introspectableObj + } else { + if (path[msg.path.length] !== '/') { + continue + } + const localPath = path.slice(msg.path.length) + const pathParts = localPath.split('/') + const localName = pathParts[1] + nodes[localName] = null + } + } + } + + const length = Object.keys(nodes).length + let obj + if (length === 0) { + resultXml.push('') + } else if (length === 1) { + obj = nodes[Object.keys(nodes)[0]] + if (obj) { + resultXml.push('') + for (const ifaceNode in obj) { + resultXml.push(interfaceToXML(obj[ifaceNode][0])) + } + resultXml.push(stdIfaces) + resultXml.push('') + } else { + resultXml.push( + `\n \n `, + ) + } + } else { + resultXml.push('') + for (const name in nodes) { + if (nodes[name] === null) { + resultXml.push(` `) + } else { + obj = nodes[name] + resultXml.push(` `) + for (const ifaceName in obj) { + resultXml.push(interfaceToXML(obj[ifaceName][0])) + } + resultXml.push(stdIfaces) + resultXml.push(' ') + } + } + resultXml.push('') + } + + const introspectableReply = { + type: constants.messageType.methodReturn, + serial: bus.serial++, + replySerial: msg.serial, + destination: msg.sender, + signature: 's', + body: [resultXml.join('\n')], + } + bus.connection.message(introspectableReply) + return 1 + } else if (msg.interface === 'org.freedesktop.DBus.Properties') { + const interfaceName = msg.body[0] + const propertiesObj = bus.exportedObjects[msg.path] + // TODO: !propertiesObj -> UnknownObject http://www.freedesktop.org/wiki/Software/DBusBindingErrors + if (!propertiesObj || !propertiesObj[interfaceName]) { + // TODO: + bus.sendError( + msg, + 'org.freedesktop.DBus.Error.UnknownMethod', + 'Uh oh oh', + ) + return 1 + } + const impl = propertiesObj[interfaceName][1] + + const propertiesReply = { + type: constants.messageType.methodReturn, + serial: bus.serial++, + replySerial: msg.serial, + destination: msg.sender, + } + if (msg.member === 'Get' || msg.member === 'Set') { + const propertyName = msg.body[1] + const propType = propertiesObj[interfaceName][0].properties[propertyName] + if (msg.member === 'Get') { + const propValue = impl[propertyName] + propertiesReply.signature = 'v' + propertiesReply.body = [[propType, propValue]] + } else { + impl[propertyName] = 1234 // TODO: read variant and set property value + } + } else if (msg.member === 'GetAll') { + propertiesReply.signature = 'a{sv}' + const props = [] + for (const p in propertiesObj[interfaceName][0].properties) { + const propertySignature = propertiesObj[interfaceName][0].properties[p] + props.push([p, [propertySignature, impl[p]]]) + } + propertiesReply.body = [props] + } + bus.connection.message(propertiesReply) + return 1 + } else if (msg.interface === 'org.freedesktop.DBus.Peer') { + // TODO: implement bus.replyTo(srcMsg, signature, body) method + const peerReply = { + type: constants.messageType.methodReturn, + serial: bus.serial++, + replySerial: msg.serial, + destination: msg.sender, + } + if (msg.member === 'Ping') { + // empty body + } else if (msg.member === 'GetMachineId') { + peerReply.signature = 's' + peerReply.body = ['This is a machine id. TODO: implement'] + } + bus.connection.message(peerReply) + return 1 + } + return 0 +} + +function interfaceToXML(iface) { + const result = [] + const dumpArgs = function (argsSignature, argsNames, direction) { + if (!argsSignature) { + return + } + const args = parseSignature(argsSignature) + args.forEach((arg, num) => { + const argName = argsNames ? argsNames[num] : direction + num + const dirStr = direction === 'signal' ? '' : `" direction="${direction}` + result.push( + ` `, + ) + }) + } + result.push(` `) + if (iface.methods) { + for (const methodName in iface.methods) { + const method = iface.methods[methodName] + result.push(` `) + dumpArgs(method[0], method[2], 'in') + dumpArgs(method[1], method[3], 'out') + result.push(' ') + } + } + if (iface.signals) { + for (const signalName in iface.signals) { + const signal = iface.signals[signalName] + result.push(` `) + dumpArgs(signal[0], signal.slice(1), 'signal') + result.push(' ') + } + } + if (iface.properties) { + for (const propertyName in iface.properties) { + // TODO: decide how to encode access + result.push( + ` `, + ) + } + } + result.push(' ') + return result.join('\n') +} + +function dumpSignature(s) { + const result = [] + s.forEach((sig) => { + result.push(sig.type + dumpSignature(sig.child)) + if (sig.type === '{') { + result.push('}') + } + if (sig.type === '(') { + result.push(')') + } + }) + return result.join('') +} +stdIfaces + = ' \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n '