Skip to content

Commit

Permalink
chore(connect): nothing below DeviceCommands.typedCall shall throw
Browse files Browse the repository at this point in the history
  • Loading branch information
mroz22 committed Feb 5, 2025
1 parent dd82975 commit 403f0d0
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 122 deletions.
2 changes: 1 addition & 1 deletion packages/connect-popup/e2e/tests/passphrase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ test('introduce passphrase in popup and device rejects it', async () => {

await waitAndClick(popup, ['@connect-ui/error-close-button']);

await explorerPage.waitForSelector('text=Failure_ActionCancelled');
await explorerPage.waitForSelector('text=Cancelled');
});

test('introduce passphrase successfully next time should not ask for it', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/connect-popup/e2e/tests/popup-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ test(`device dialog canceled ON DEVICE by user`, async ({ page, context }) => {

await popupClosedPromise;

await explorerPage.waitForSelector('text=Failure_ActionCancelled');
await explorerPage.waitForSelector('text=Cancelled');
});

test(`device disconnected during device interaction`, async ({ page, context }) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/connect/src/device/Device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export class Device extends TypedEmitter<DeviceEvents> {

private keepTransportSession = false;
public commands?: DeviceCommands;
private cancelableAction?: (err?: Error) => Promise<unknown>;
private cancelableAction?: (err?: string) => Promise<unknown>;

private loaded = false;

Expand Down Expand Up @@ -462,7 +462,7 @@ export class Device extends TypedEmitter<DeviceEvents> {
}

setCancelableAction(callback: NonNullable<typeof this.cancelableAction>) {
this.cancelableAction = (e?: Error) =>
this.cancelableAction = (e?: string) =>
callback(e)
.catch(e2 => {
_log.debug('cancelableAction error', e2);
Expand All @@ -479,7 +479,7 @@ export class Device extends TypedEmitter<DeviceEvents> {
async interruptionFromUser(error: Error) {
_log.debug('interruptionFromUser');

await this.cancelableAction?.(error);
await this.cancelableAction?.(error.toString());
await this.commands?.cancel();

if (this.runPromise) {
Expand Down
226 changes: 127 additions & 99 deletions packages/connect/src/device/DeviceCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { MessagesSchema as Messages } from '@trezor/protobuf';
import { Assert } from '@trezor/schema-utils';
import { Session, Transport } from '@trezor/transport';
import { ReadWriteError } from '@trezor/transport/src/transports/abstract';
import { createTimeoutPromise, versionUtils } from '@trezor/utils';

import { ERRORS } from '../constants';
Expand Down Expand Up @@ -31,16 +32,6 @@ type DefaultPayloadMessage = TypedCallResponseMap[keyof MessageType];

const logger = initLog('DeviceCommands');

const assertType = (res: DefaultPayloadMessage, resType: MessageKey | MessageKey[]) => {
const splitResTypes = Array.isArray(resType) ? resType : resType.split('|');
if (!splitResTypes.includes(res.type)) {
throw ERRORS.TypedError(
'Runtime',
`assertType: Response of unexpected type: ${res.type}. Should be ${resType}`,
);
}
};

const filterForLog = (type: string, msg: any) => {
const blacklist: { [key: string]: Record<string, string> | string } = {
PassphraseAck: {
Expand Down Expand Up @@ -294,44 +285,6 @@ export class DeviceCommands {
return this._getAddress();
}

// Sends an async message to the opened device.
private async call(
type: MessageKey,
msg: DefaultPayloadMessage['message'] = {},
): Promise<DefaultPayloadMessage> {
logger.debug('Sending', type, filterForLog(type, msg));

this.callPromise = this.transport.call({
session: this.transportSession,
name: type,
data: msg,
protocol: this.device.protocol,
});

const res = await this.callPromise;

this.callPromise = undefined;
if (!res.success) {
logger.warn(
'Received error',
res.error,
// res.message is not propagated to higher levels, only logged here. webusb/node-bridge may return message with additional information
res.message,
);
throw new Error(res.error);
}

logger.debug(
'Received',
res.payload.type,
filterForLog(res.payload.type, res.payload.message),
);

// TODO: https://github.com/trezor/trezor-suite/issues/5301
// @ts-expect-error
return res.payload;
}

typedCall<T extends MessageKey, R extends MessageKey[]>(
type: T,
resType: R,
Expand All @@ -353,43 +306,103 @@ export class DeviceCommands {
// Assert message type
// msg is allowed to be undefined for some calls, in that case the schema is an empty object
Assert(Messages.MessageType.properties[type], msg ?? {});

const response = await this._commonCall(type, msg);
try {
assertType(response, resType);
} catch (error) {
// handle possible race condition
// Bridge may have some unread message in buffer, read it
const abortController = new AbortController();
const timeout = setTimeout(() => {
abortController.abort();
}, 500);

await this.transport
.receive({
session: this.transportSession,
protocol: this.device.protocol,
signal: abortController.signal,
})
.finally(() => {
clearTimeout(timeout);
});
// throw error anyway, next call should be resolved properly
throw error;

if (!response.success) {
if (response.isTransportError) {
// todo: at this point we have 2 options:
// A - throw { cause: 'transport-error' } plus update to ES2022
// B - rework also implementations of typedCall so that they never throw
throw new Error(response.error);
} else {
throw new Error(response.message);
}
}

return response;
const splitResTypes = Array.isArray(resType) ? resType : resType.split('|');
if (splitResTypes.includes(response.payload.type)) {
return response.payload;
}
// handle possible race condition
// Bridge may have some unread message in buffer, read it
const abortController = new AbortController();
const timeout = setTimeout(() => {
abortController.abort();
}, 500);

await this.transport
.receive({
session: this.transportSession,
protocol: this.device.protocol,
signal: abortController.signal,
})
.finally(() => {
clearTimeout(timeout);
});

// throw error anyway, next call should be resolved properly

throw ERRORS.TypedError(
'Runtime',
`assertType: Response of unexpected type: ${response.payload.type}. Should be ${resType}`,
);
}

async _commonCall(type: MessageKey, msg?: DefaultPayloadMessage['message']) {
async _commonCall(
type: MessageKey,
msg: DefaultPayloadMessage['message'] = {},
): Promise<
| { success: true; payload: DefaultPayloadMessage }
| { success: false; error: any; message: string; isTransportError: false }
| { success: false; error: ReadWriteError; message?: string; isTransportError: true }
> {
if (this.disposed) {
throw ERRORS.TypedError('Runtime', 'typedCall: DeviceCommands already disposed');
}
const resp = await this.call(type, msg);

return this._filterCommonTypes(resp);
logger.debug('Sending', type, filterForLog(type, msg));

this.callPromise = this.transport.call({
session: this.transportSession,
name: type,
data: msg,
protocol: this.device.protocol,
});

const res = await this.callPromise;
this.callPromise = undefined;

if (!res.success) {
logger.warn(
'Received error',
res.error,
// res.message is not propagated to higher levels, only logged here. webusb/node-bridge may return message with additional information
res.message,
);

return {
...res,
isTransportError: true,
};
}

logger.debug(
'Received',
res.payload.type,
filterForLog(res.payload.type, res.payload.message),
);

return this._filterCommonTypes(res.payload as DefaultPayloadMessage);
}

_filterCommonTypes(res: DefaultPayloadMessage): Promise<DefaultPayloadMessage> {
_filterCommonTypes(
res: DefaultPayloadMessage,
): Promise<
| { success: true; payload: DefaultPayloadMessage }
| { success: false; error: any; message: string; isTransportError: false }
| { success: false; error: ReadWriteError; message?: string; isTransportError: true }
> {
this.device.clearCancelableAction();

if (res.type === 'Failure') {
Expand All @@ -409,16 +422,17 @@ export class DeviceCommands {
}

// pass code and message from firmware error
return Promise.reject(
new ERRORS.TrezorError(
(code as any) || 'Failure_UnknownCode',
message || 'Failure_UnknownMessage',
),
);
return Promise.resolve({
success: false,
// todo: check renaming error vs code
error: code || 'Failure_UnknownCode',
message: message || 'Failure_UnknownMessage',
isTransportError: false,
});
}

if (res.type === 'Features') {
return Promise.resolve(res);
return Promise.resolve({ success: true, payload: res });
}

if (res.type === 'ButtonRequest') {
Expand All @@ -434,38 +448,52 @@ export class DeviceCommands {
}

if (res.type === 'PinMatrixRequest') {
return promptPin(this.device, res.message.type).then(
pin =>
this._commonCall('PinMatrixAck', { pin }).then(response => {
return promptPin(this.device, res.message.type).then(promptRes => {
if (!promptRes.success) {
return promptRes;
}

return this._commonCall('PinMatrixAck', { pin: promptRes.payload }).then(
response => {
if (!response.success) {
return response;
}
if (!this.device.features.unlocked) {
// reload features to after successful PIN
return this.device.getFeatures().then(() => response);
}

return response;
}),
error => Promise.reject(error),
);
},
);
});
}

// { value, passphraseOnDevice }
if (res.type === 'PassphraseRequest') {
return promptPassphrase(this.device).then(
({ value, passphraseOnDevice }) =>
!passphraseOnDevice
? this._commonCall('PassphraseAck', { passphrase: value.normalize('NFKD') })
: this._commonCall('PassphraseAck', { on_device: true }),
error => Promise.reject(error),
);
return promptPassphrase(this.device).then(promptRes => {
if (!promptRes.success) {
return promptRes;
}
const { value, passphraseOnDevice } = promptRes.payload;

return !passphraseOnDevice
? this._commonCall('PassphraseAck', { passphrase: value.normalize('NFKD') })
: this._commonCall('PassphraseAck', { on_device: true });
});
}

if (res.type === 'WordRequest') {
return promptWord(this.device, res.message.type).then(
word => this._commonCall('WordAck', { word }),
error => Promise.reject(error),
);
return promptWord(this.device, res.message.type).then(promptRes => {
if (!promptRes.success) {
return promptRes;
}

return this._commonCall('WordAck', { word: promptRes.payload });
});
}

return Promise.resolve(res);
return Promise.resolve({ success: true, payload: res });
}

private async _getAddress() {
Expand Down
Loading

0 comments on commit 403f0d0

Please sign in to comment.