mirror of
https://git.proxmox.com/git/fwupd
synced 2025-06-03 17:04:54 +00:00

The CustomFlags feature is a bit of a hack where we just join the flags and store in the device metadata section as a string. This makes it inefficient to check if just one flag exists as we have to split the string to a temporary array each time. Rather than adding to the hack by splitting, appending (if not exists) then joining again, store the flags in the plugin privdata directly. This allows us to support negating custom properties (e.g. ~hint) and also allows quirks to append custom values without duplicating them on each GUID match, e.g. [USB\VID_17EF&PID_307F] Plugin = customflag1 [USB\VID_17EF&PID_307F&HUB_0002] Flags = customflag2 ...would result in customflag1,customflag2 which is the same as you'd get from an enumerated device flag doing the same thing.
1054 lines
32 KiB
C
1054 lines
32 KiB
C
/*
|
|
* Copyright (C) 2020 Benson Leung <bleung@chromium.org>
|
|
*
|
|
* SPDX-License-Identifier: LGPL-2.1+
|
|
*/
|
|
|
|
#include "config.h"
|
|
|
|
#include <fwupdplugin.h>
|
|
#include <string.h>
|
|
|
|
#include "fu-cros-ec-usb-device.h"
|
|
#include "fu-cros-ec-common.h"
|
|
#include "fu-cros-ec-firmware.h"
|
|
|
|
#define USB_SUBCLASS_GOOGLE_UPDATE 0x53
|
|
#define USB_PROTOCOL_GOOGLE_UPDATE 0xff
|
|
|
|
#define SETUP_RETRY_CNT 5
|
|
#define MAX_BLOCK_XFER_RETRIES 10
|
|
#define FLUSH_TIMEOUT_MS 10
|
|
#define BULK_SEND_TIMEOUT_MS 2000
|
|
#define BULK_RECV_TIMEOUT_MS 5000
|
|
#define CROS_EC_REMOVE_DELAY_RE_ENUMERATE 20000
|
|
|
|
#define UPDATE_DONE 0xB007AB1E
|
|
#define UPDATE_EXTRA_CMD 0xB007AB1F
|
|
|
|
enum update_extra_command {
|
|
UPDATE_EXTRA_CMD_IMMEDIATE_RESET = 0,
|
|
UPDATE_EXTRA_CMD_JUMP_TO_RW = 1,
|
|
UPDATE_EXTRA_CMD_STAY_IN_RO = 2,
|
|
UPDATE_EXTRA_CMD_UNLOCK_RW = 3,
|
|
UPDATE_EXTRA_CMD_UNLOCK_ROLLBACK = 4,
|
|
UPDATE_EXTRA_CMD_INJECT_ENTROPY = 5,
|
|
UPDATE_EXTRA_CMD_PAIR_CHALLENGE = 6,
|
|
UPDATE_EXTRA_CMD_TOUCHPAD_INFO = 7,
|
|
UPDATE_EXTRA_CMD_TOUCHPAD_DEBUG = 8,
|
|
UPDATE_EXTRA_CMD_CONSOLE_READ_INIT = 9,
|
|
UPDATE_EXTRA_CMD_CONSOLE_READ_NEXT = 10,
|
|
};
|
|
|
|
struct _FuCrosEcUsbDevice {
|
|
FuUsbDevice parent_instance;
|
|
guint8 iface_idx; /* bInterfaceNumber */
|
|
guint8 ep_num; /* bEndpointAddress */
|
|
guint16 chunk_len; /* wMaxPacketSize */
|
|
|
|
struct first_response_pdu targ;
|
|
guint32 writeable_offset;
|
|
guint16 protocol_version;
|
|
guint16 header_type;
|
|
struct cros_ec_version version; /* version of other region */
|
|
struct cros_ec_version active_version; /* version of active region */
|
|
gchar configuration[FU_CROS_EC_STRLEN];
|
|
gboolean in_bootloader;
|
|
};
|
|
|
|
G_DEFINE_TYPE (FuCrosEcUsbDevice, fu_cros_ec_usb_device, FU_TYPE_USB_DEVICE)
|
|
|
|
typedef union _START_RESP {
|
|
struct first_response_pdu rpdu;
|
|
guint32 legacy_resp;
|
|
} START_RESP;
|
|
|
|
typedef struct {
|
|
struct update_frame_header ufh;
|
|
GBytes *image_bytes;
|
|
gsize offset;
|
|
gsize payload_size;
|
|
} FuCrosEcUsbBlockInfo;
|
|
|
|
#define FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN (1 << 0)
|
|
#define FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN (1 << 1)
|
|
#define FU_CROS_EC_USB_DEVICE_FLAG_REBOOTING_TO_RO (1 << 2)
|
|
#define FU_CROS_EC_USB_DEVICE_FLAG_SPECIAL (1 << 3)
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_get_configuration (FuCrosEcUsbDevice *self,
|
|
GError **error)
|
|
{
|
|
GUsbDevice *usb_device = fu_usb_device_get_dev (FU_USB_DEVICE (self));
|
|
guint8 index;
|
|
g_autofree gchar *configuration = NULL;
|
|
|
|
#if G_USB_CHECK_VERSION(0,3,5)
|
|
index = g_usb_device_get_configuration_index (usb_device);
|
|
#else
|
|
g_set_error_literal (error,
|
|
FWUPD_ERROR,
|
|
FWUPD_ERROR_NOT_SUPPORTED,
|
|
"this version of GUsb is not supported");
|
|
return FALSE;
|
|
#endif
|
|
configuration = g_usb_device_get_string_descriptor (usb_device,
|
|
index,
|
|
error);
|
|
if (configuration == NULL)
|
|
return FALSE;
|
|
|
|
if (g_strlcpy (self->configuration, configuration, FU_CROS_EC_STRLEN) == 0) {
|
|
g_set_error_literal (error,
|
|
FWUPD_ERROR,
|
|
FWUPD_ERROR_INTERNAL,
|
|
"empty iConfiguration");
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_find_interface (FuUsbDevice *device,
|
|
GError **error)
|
|
{
|
|
GUsbDevice *usb_device = fu_usb_device_get_dev (device);
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
g_autoptr(GPtrArray) intfs = NULL;
|
|
|
|
/* based on usb_updater2's find_interfacei() and find_endpoint() */
|
|
|
|
intfs = g_usb_device_get_interfaces (usb_device, error);
|
|
if (intfs == NULL)
|
|
return FALSE;
|
|
for (guint i = 0; i < intfs->len; i++) {
|
|
GUsbInterface *intf = g_ptr_array_index (intfs, i);
|
|
if (g_usb_interface_get_class (intf) == 255 &&
|
|
g_usb_interface_get_subclass (intf) == USB_SUBCLASS_GOOGLE_UPDATE &&
|
|
g_usb_interface_get_protocol (intf) == USB_PROTOCOL_GOOGLE_UPDATE) {
|
|
GUsbEndpoint *ep;
|
|
g_autoptr(GPtrArray) endpoints = NULL;
|
|
|
|
endpoints = g_usb_interface_get_endpoints (intf);
|
|
if (NULL == endpoints || 0 == endpoints->len)
|
|
continue;
|
|
ep = g_ptr_array_index (endpoints, 0);
|
|
self->iface_idx = g_usb_interface_get_number (intf);
|
|
self->ep_num = g_usb_endpoint_get_address (ep) & 0x7f;
|
|
self->chunk_len = g_usb_endpoint_get_maximum_packet_size (ep);
|
|
|
|
return TRUE;
|
|
}
|
|
}
|
|
g_set_error_literal (error,
|
|
FWUPD_ERROR,
|
|
FWUPD_ERROR_NOT_FOUND,
|
|
"no update interface found");
|
|
return FALSE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_open (FuDevice *device, GError **error)
|
|
{
|
|
GUsbDevice *usb_device = fu_usb_device_get_dev (FU_USB_DEVICE (device));
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
|
|
/* FuUsbDevice->open */
|
|
if (!FU_DEVICE_CLASS (fu_cros_ec_usb_device_parent_class)->open (device, error))
|
|
return FALSE;
|
|
|
|
if (!g_usb_device_claim_interface (usb_device, self->iface_idx,
|
|
G_USB_DEVICE_CLAIM_INTERFACE_BIND_KERNEL_DRIVER,
|
|
error)) {
|
|
g_prefix_error (error, "failed to claim interface: ");
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_probe (FuDevice *device, GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
|
|
/* FuUsbDevice->probe */
|
|
if (!FU_DEVICE_CLASS (fu_cros_ec_usb_device_parent_class)->probe (device, error))
|
|
return FALSE;
|
|
|
|
/* very much like usb_updater2's usb_findit() */
|
|
|
|
if (!fu_cros_ec_usb_device_find_interface (FU_USB_DEVICE (device), error)) {
|
|
g_prefix_error (error, "failed to find update interface: ");
|
|
return FALSE;
|
|
}
|
|
|
|
if (self->chunk_len == 0) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_INVALID_DATA,
|
|
"wMaxPacketSize isn't valid: %" G_GUINT16_FORMAT,
|
|
self->chunk_len);
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_do_xfer (FuCrosEcUsbDevice * self, const guint8 *outbuf,
|
|
gsize outlen, guint8 *inbuf, gsize inlen,
|
|
gboolean allow_less, gsize *rxed_count,
|
|
GError **error)
|
|
{
|
|
GUsbDevice *usb_device = fu_usb_device_get_dev (FU_USB_DEVICE (self));
|
|
gsize actual = 0;
|
|
|
|
/* send data out */
|
|
if (outbuf != NULL && outlen > 0) {
|
|
g_autofree guint8 *outbuf_tmp = NULL;
|
|
|
|
/* make mutable */
|
|
outbuf_tmp = fu_memdup_safe (outbuf, outlen, error);
|
|
if (outbuf_tmp == NULL)
|
|
return FALSE;
|
|
|
|
if (!g_usb_device_bulk_transfer (usb_device, self->ep_num,
|
|
outbuf_tmp, outlen,
|
|
&actual, BULK_SEND_TIMEOUT_MS,
|
|
NULL, error)) {
|
|
return FALSE;
|
|
}
|
|
if (actual != outlen) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_PARTIAL_INPUT,
|
|
"only sent %" G_GSIZE_FORMAT "/%"
|
|
G_GSIZE_FORMAT " bytes",
|
|
actual, outlen);
|
|
return FALSE;
|
|
}
|
|
}
|
|
|
|
/* read reply back */
|
|
if (inbuf != NULL && inlen > 0) {
|
|
actual = 0;
|
|
if (!g_usb_device_bulk_transfer (usb_device,
|
|
self->ep_num | 0x80,
|
|
inbuf, inlen,
|
|
&actual, BULK_RECV_TIMEOUT_MS,
|
|
NULL, error)) {
|
|
return FALSE;
|
|
}
|
|
if (actual != inlen && !allow_less) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_PARTIAL_INPUT,
|
|
"only received %" G_GSIZE_FORMAT "/%"
|
|
G_GSIZE_FORMAT " bytes",
|
|
actual, inlen);
|
|
return FALSE;
|
|
}
|
|
}
|
|
|
|
if (rxed_count != NULL)
|
|
*rxed_count = actual;
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_flush (FuDevice *device, gpointer user_data,
|
|
GError **error)
|
|
{
|
|
GUsbDevice *usb_device = fu_usb_device_get_dev (FU_USB_DEVICE (device));
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
gsize actual = 0;
|
|
g_autofree guint8 *inbuf = g_malloc0 (self->chunk_len);
|
|
|
|
/* bulk transfer expected to fail normally (ie, no stale data)
|
|
* but if bulk transfer succeeds, indicates stale bytes on the device
|
|
* so this will retry until they're emptied */
|
|
if (g_usb_device_bulk_transfer (usb_device, self->ep_num | 0x80, inbuf,
|
|
self->chunk_len, &actual,
|
|
FLUSH_TIMEOUT_MS, NULL, NULL)) {
|
|
g_debug ("flushing %" G_GSIZE_FORMAT " bytes", actual);
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_FAILED,
|
|
"flushing %" G_GSIZE_FORMAT " bytes", actual);
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_recovery (FuDevice *device, GError **error)
|
|
{
|
|
/* flush all data from endpoint to recover in case of error */
|
|
if (!fu_device_retry (device, fu_cros_ec_usb_device_flush,
|
|
SETUP_RETRY_CNT, NULL, error)) {
|
|
g_prefix_error (error, "failed to flush device to idle state: ");
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
/*
|
|
* Channel TPM extension/vendor command over USB. The payload of the USB frame
|
|
* in this case consists of the 2 byte subcommand code concatenated with the
|
|
* command body. The caller needs to indicate if a response is expected, and
|
|
* if it is - of what maximum size.
|
|
*/
|
|
static gboolean
|
|
fu_cros_ec_usb_ext_cmd (FuDevice *device, guint16 subcommand,
|
|
gpointer cmd_body, gsize body_size,
|
|
gpointer resp, gsize *resp_size,
|
|
gboolean allow_less, GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
guint16 *frame_ptr;
|
|
gsize usb_msg_size = sizeof (struct update_frame_header) +
|
|
sizeof (subcommand) + body_size;
|
|
g_autofree struct update_frame_header *ufh = g_malloc0 (usb_msg_size);
|
|
|
|
ufh->block_size = GUINT32_TO_BE (usb_msg_size);
|
|
ufh->cmd.block_digest = 0;
|
|
ufh->cmd.block_base = GUINT32_TO_BE (UPDATE_EXTRA_CMD);
|
|
frame_ptr = (guint16 *)(ufh + 1);
|
|
*frame_ptr = GUINT16_TO_BE (subcommand);
|
|
|
|
if (body_size != 0) {
|
|
gsize offset = sizeof (struct update_frame_header) + sizeof (subcommand);
|
|
if (!fu_memcpy_safe ((guint8 *) ufh, usb_msg_size, offset,
|
|
(const guint8 *) cmd_body, body_size,
|
|
0x0, body_size, error))
|
|
return FALSE;
|
|
}
|
|
|
|
return fu_cros_ec_usb_device_do_xfer (self, (const guint8 *)ufh, usb_msg_size,
|
|
(guint8 *)resp,
|
|
resp_size != NULL ? *resp_size : 0,
|
|
TRUE, NULL, error);
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_start_request (FuDevice *device, gpointer user_data,
|
|
GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
guint8 *start_resp = (guint8 *) user_data;
|
|
struct update_frame_header ufh;
|
|
gsize rxed_size = 0;
|
|
|
|
memset(&ufh, 0, sizeof (ufh));
|
|
ufh.block_size = GUINT32_TO_BE (sizeof(ufh));
|
|
if (!fu_cros_ec_usb_device_do_xfer (self, (const guint8 *)&ufh, sizeof(ufh),
|
|
start_resp,
|
|
sizeof(START_RESP), TRUE,
|
|
&rxed_size, error))
|
|
return FALSE;
|
|
|
|
/* we got something, so check for errors in response */
|
|
if (rxed_size < 8) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_PARTIAL_INPUT,
|
|
"unexpected response size %" G_GSIZE_FORMAT,
|
|
rxed_size);
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_setup (FuDevice *device, GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
guint32 error_code;
|
|
START_RESP start_resp;
|
|
g_auto(GStrv) config_split = NULL;
|
|
|
|
/* FuUsbDevice->setup */
|
|
if (!FU_DEVICE_CLASS (fu_cros_ec_usb_device_parent_class)->setup (device, error))
|
|
return FALSE;
|
|
|
|
if (!fu_cros_ec_usb_device_recovery (device, error))
|
|
return FALSE;
|
|
|
|
/* send start request */
|
|
if (!fu_device_retry (device, fu_cros_ec_usb_device_start_request,
|
|
SETUP_RETRY_CNT, &start_resp, error)) {
|
|
g_prefix_error (error, "failed to send start request: ");
|
|
return FALSE;
|
|
}
|
|
|
|
self->protocol_version = GUINT16_FROM_BE (start_resp.rpdu.protocol_version);
|
|
|
|
if (self->protocol_version < 5 || self->protocol_version > 6) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_NOT_SUPPORTED,
|
|
"unsupported protocol version %d",
|
|
self->protocol_version);
|
|
return FALSE;
|
|
}
|
|
self->header_type = GUINT16_FROM_BE (start_resp.rpdu.header_type);
|
|
|
|
error_code = GUINT32_FROM_BE (start_resp.rpdu.return_value);
|
|
if (error_code != 0) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_FAILED,
|
|
"target reporting error %u", error_code);
|
|
return FALSE;
|
|
}
|
|
|
|
self->writeable_offset = GUINT32_FROM_BE (start_resp.rpdu.common.offset);
|
|
if (!fu_memcpy_safe ((guint8 *) self->targ.common.version,
|
|
FU_CROS_EC_STRLEN, 0x0,
|
|
(const guint8 *) start_resp.rpdu.common.version,
|
|
sizeof(start_resp.rpdu.common.version), 0x0,
|
|
sizeof(start_resp.rpdu.common.version), error))
|
|
return FALSE;
|
|
self->targ.common.maximum_pdu_size =
|
|
GUINT32_FROM_BE (start_resp.rpdu.common.maximum_pdu_size);
|
|
self->targ.common.flash_protection =
|
|
GUINT32_FROM_BE (start_resp.rpdu.common.flash_protection);
|
|
self->targ.common.min_rollback = GINT32_FROM_BE (start_resp.rpdu.common.min_rollback);
|
|
self->targ.common.key_version = GUINT32_FROM_BE (start_resp.rpdu.common.key_version);
|
|
|
|
/* get active version string and running region from iConfiguration */
|
|
if (!fu_cros_ec_usb_device_get_configuration (self, error))
|
|
return FALSE;
|
|
config_split = g_strsplit (self->configuration, ":", 2);
|
|
if (g_strv_length (config_split) < 2) {
|
|
/* no prefix found so fall back to offset */
|
|
self->in_bootloader = self->writeable_offset != 0x0;
|
|
if (!fu_cros_ec_parse_version (self->configuration,
|
|
&self->active_version, error)) {
|
|
g_prefix_error (error,
|
|
"failed parsing device's version: %32s: ",
|
|
self->configuration);
|
|
return FALSE;
|
|
}
|
|
} else {
|
|
self->in_bootloader = g_strcmp0 ("RO", config_split[0]) == 0;
|
|
if (!fu_cros_ec_parse_version (config_split[1],
|
|
&self->active_version, error)) {
|
|
g_prefix_error (error,
|
|
"failed parsing device's version: %32s: ",
|
|
config_split[1]);
|
|
return FALSE;
|
|
}
|
|
}
|
|
|
|
/* get the other region's version string from targ */
|
|
if (!fu_cros_ec_parse_version (self->targ.common.version,
|
|
&self->version, error)) {
|
|
g_prefix_error (error,
|
|
"failed parsing device's version: %32s: ",
|
|
self->targ.common.version);
|
|
return FALSE;
|
|
}
|
|
|
|
if (self->in_bootloader) {
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_IS_BOOTLOADER);
|
|
fu_device_set_version (FU_DEVICE (device), self->version.triplet);
|
|
fu_device_set_version_bootloader (FU_DEVICE (device),
|
|
self->active_version.triplet);
|
|
} else {
|
|
fu_device_remove_flag (device, FWUPD_DEVICE_FLAG_IS_BOOTLOADER);
|
|
fu_device_set_version (FU_DEVICE (device), self->active_version.triplet);
|
|
fu_device_set_version_bootloader (FU_DEVICE (device),
|
|
self->version.triplet);
|
|
}
|
|
fu_device_add_instance_id (FU_DEVICE (device), self->version.boardname);
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_transfer_block (FuDevice *device, gpointer user_data,
|
|
GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
FuCrosEcUsbBlockInfo *block_info = (FuCrosEcUsbBlockInfo *) user_data;
|
|
gsize image_size = 0;
|
|
gsize transfer_size = 0;
|
|
guint32 reply = 0;
|
|
g_autoptr(GBytes) block_bytes = NULL;
|
|
g_autoptr(GPtrArray) chunks = NULL;
|
|
|
|
g_return_val_if_fail (block_info != NULL, FALSE);
|
|
|
|
image_size = g_bytes_get_size (block_info->image_bytes);
|
|
if (block_info->offset + block_info->payload_size > image_size) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_INVALID_DATA,
|
|
"offset %" G_GSIZE_FORMAT "plus payload_size %"
|
|
G_GSIZE_FORMAT " exceeds image size %"
|
|
G_GSIZE_FORMAT,
|
|
block_info->offset, block_info->payload_size,
|
|
image_size);
|
|
return FALSE;
|
|
}
|
|
|
|
block_bytes = fu_common_bytes_new_offset (block_info->image_bytes,
|
|
block_info->offset,
|
|
block_info->payload_size,
|
|
error);
|
|
if (block_bytes == NULL)
|
|
return FALSE;
|
|
chunks = fu_chunk_array_new_from_bytes (block_bytes,
|
|
0x00,
|
|
0x00,
|
|
self->chunk_len);
|
|
|
|
/* first send the header */
|
|
if (!fu_cros_ec_usb_device_do_xfer (self, (const guint8 *)&block_info->ufh,
|
|
sizeof(struct update_frame_header),
|
|
NULL,
|
|
0, FALSE,
|
|
NULL, error)) {
|
|
g_autoptr(GError) error_flush = NULL;
|
|
/* flush all data from endpoint to recover in case of error */
|
|
if (!fu_cros_ec_usb_device_recovery (device, &error_flush)) {
|
|
g_debug ("failed to flush to idle: %s",
|
|
error_flush->message);
|
|
}
|
|
g_prefix_error (error, "failed at sending header: ");
|
|
return FALSE;
|
|
}
|
|
|
|
/* send the block, chunk by chunk */
|
|
for (guint i = 0; i < chunks->len; i++) {
|
|
FuChunk *chk = g_ptr_array_index (chunks, i);
|
|
|
|
if (!fu_cros_ec_usb_device_do_xfer (self,
|
|
fu_chunk_get_data (chk),
|
|
fu_chunk_get_data_sz (chk),
|
|
NULL,
|
|
0, FALSE,
|
|
NULL, error)) {
|
|
g_autoptr(GError) error_flush = NULL;
|
|
g_prefix_error (error, "failed at sending chunk: ");
|
|
|
|
/* flush all data from endpoint to recover in case of error */
|
|
if (!fu_cros_ec_usb_device_recovery (device, &error_flush)) {
|
|
g_debug ("failed to flush to idle: %s",
|
|
error_flush->message);
|
|
}
|
|
return FALSE;
|
|
}
|
|
}
|
|
|
|
/* get the reply */
|
|
if (!fu_cros_ec_usb_device_do_xfer (self, NULL, 0,
|
|
(guint8 *)&reply, sizeof (reply),
|
|
TRUE, &transfer_size, error)) {
|
|
g_autoptr(GError) error_flush = NULL;
|
|
g_prefix_error (error, "failed at reply: ");
|
|
/* flush all data from endpoint to recover in case of error */
|
|
if (!fu_cros_ec_usb_device_recovery (device, &error_flush)) {
|
|
g_debug ("failed to flush to idle: %s",
|
|
error_flush->message);
|
|
}
|
|
return FALSE;
|
|
}
|
|
if (transfer_size == 0) {
|
|
g_set_error_literal (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_FAILED,
|
|
"zero bytes received for block reply");
|
|
return FALSE;
|
|
}
|
|
if (reply != 0) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_FAILED,
|
|
"error: status 0x%#x", reply);
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_transfer_section (FuDevice *device,
|
|
FuFirmware *firmware,
|
|
FuCrosEcFirmwareSection *section,
|
|
GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
const guint8 * data_ptr = NULL;
|
|
guint32 section_addr = 0;
|
|
gsize data_len = 0;
|
|
gsize offset = 0;
|
|
g_autoptr(GBytes) img_bytes = NULL;
|
|
|
|
g_return_val_if_fail (section != NULL, FALSE);
|
|
|
|
section_addr = section->offset;
|
|
img_bytes = fu_firmware_get_image_by_idx_bytes (firmware,
|
|
section->image_idx,
|
|
error);
|
|
if (img_bytes == NULL) {
|
|
g_prefix_error (error, "failed to find section image: ");
|
|
return FALSE;
|
|
}
|
|
|
|
data_ptr = (const guint8 *)g_bytes_get_data (img_bytes, &data_len);
|
|
if (data_ptr == NULL || data_len != section->size) {
|
|
g_set_error (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_INVALID_DATA,
|
|
"image and section sizes do not match: image = %"
|
|
G_GSIZE_FORMAT " bytes vs section size = %"
|
|
G_GSIZE_FORMAT " bytes",
|
|
data_len, section->size);
|
|
return FALSE;
|
|
}
|
|
|
|
/* smart update: trim trailing bytes */
|
|
while (data_len != 0 && (data_ptr[data_len - 1] == 0xff))
|
|
data_len--;
|
|
g_debug ("trimmed %" G_GSIZE_FORMAT " trailing bytes",
|
|
section->size - data_len);
|
|
|
|
g_debug ("sending 0x%zx bytes to %#x", data_len, section_addr);
|
|
while (data_len > 0) {
|
|
gsize payload_size;
|
|
guint32 block_base;
|
|
FuCrosEcUsbBlockInfo block_info;
|
|
|
|
/* prepare the header to prepend to the block */
|
|
block_info.image_bytes = img_bytes;
|
|
payload_size = MIN (data_len,
|
|
self->targ.common.maximum_pdu_size);
|
|
block_base = GUINT32_TO_BE (section_addr);
|
|
block_info.ufh.block_size = GUINT32_TO_BE (payload_size +
|
|
sizeof (struct update_frame_header));
|
|
block_info.ufh.cmd.block_base = block_base;
|
|
block_info.ufh.cmd.block_digest = 0;
|
|
block_info.offset = offset;
|
|
block_info.payload_size = payload_size;
|
|
|
|
if (!fu_device_retry (device,
|
|
fu_cros_ec_usb_device_transfer_block,
|
|
MAX_BLOCK_XFER_RETRIES, &block_info,
|
|
error)) {
|
|
g_prefix_error (error,
|
|
"failed to transfer block, %"
|
|
G_GSIZE_FORMAT " to go: ", data_len);
|
|
return FALSE;
|
|
}
|
|
data_len -= payload_size;
|
|
offset += payload_size;
|
|
section_addr += payload_size;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static void
|
|
fu_cros_ec_usb_device_send_done (FuDevice *device)
|
|
{
|
|
guint32 out = GUINT32_TO_BE (UPDATE_DONE);
|
|
g_autoptr(GError) error_local = NULL;
|
|
|
|
/* send stop request, ignoring reply */
|
|
if (!fu_cros_ec_usb_device_do_xfer (FU_CROS_EC_USB_DEVICE (device),
|
|
(const guint8 *)&out, sizeof (out),
|
|
(guint8 *)&out, 1,
|
|
FALSE, NULL, &error_local)) {
|
|
g_debug ("error on transfer of done: %s",
|
|
error_local->message);
|
|
}
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_send_subcommand (FuDevice *device, guint16 subcommand,
|
|
gpointer cmd_body, gsize body_size,
|
|
gpointer resp, gsize *resp_size,
|
|
gboolean allow_less, GError **error)
|
|
{
|
|
fu_cros_ec_usb_device_send_done (device);
|
|
|
|
if (!fu_cros_ec_usb_ext_cmd (device, subcommand,
|
|
cmd_body, body_size,
|
|
resp, resp_size, FALSE, error)) {
|
|
g_prefix_error (error,
|
|
"failed to send subcommand %" G_GUINT16_FORMAT ": ",
|
|
subcommand);
|
|
return FALSE;
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_reset_to_ro (FuDevice *device, GError **error)
|
|
{
|
|
guint8 response;
|
|
guint16 subcommand = UPDATE_EXTRA_CMD_IMMEDIATE_RESET;
|
|
guint8 command_body[2]; /* Max command body size. */
|
|
gsize command_body_size = 0;
|
|
gsize response_size = 1;
|
|
g_autoptr(GError) error_local = NULL;
|
|
|
|
fu_device_add_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_REBOOTING_TO_RO);
|
|
if (!fu_cros_ec_usb_device_send_subcommand (device, subcommand, command_body,
|
|
command_body_size, &response,
|
|
&response_size, FALSE, &error_local)) {
|
|
/* failure here is ok */
|
|
g_debug ("ignoring failure: %s", error_local->message);
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_jump_to_rw (FuDevice *device)
|
|
{
|
|
guint8 response;
|
|
guint16 subcommand = UPDATE_EXTRA_CMD_JUMP_TO_RW;
|
|
guint8 command_body[2]; /* Max command body size. */
|
|
gsize command_body_size = 0;
|
|
gsize response_size = 1;
|
|
|
|
if (!fu_cros_ec_usb_device_send_subcommand (device, subcommand, command_body,
|
|
command_body_size, &response,
|
|
&response_size, FALSE, NULL)) {
|
|
/* bail out early here if subcommand failed, which is normal */
|
|
return TRUE;
|
|
}
|
|
|
|
/* Jump to rw may not work, so if we've reached here, initiate a
|
|
* full reset using immediate reset */
|
|
subcommand = UPDATE_EXTRA_CMD_IMMEDIATE_RESET;
|
|
fu_cros_ec_usb_device_send_subcommand (device, subcommand, command_body,
|
|
command_body_size, &response,
|
|
&response_size, FALSE, NULL);
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_write_firmware (FuDevice *device,
|
|
FuFirmware *firmware,
|
|
FwupdInstallFlags flags,
|
|
GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
GPtrArray *sections;
|
|
FuCrosEcFirmware *cros_ec_firmware = FU_CROS_EC_FIRMWARE (firmware);
|
|
gint num_txed_sections = 0;
|
|
|
|
fu_device_remove_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_SPECIAL);
|
|
|
|
if (fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_REBOOTING_TO_RO)) {
|
|
gsize response_size = 1;
|
|
guint8 response;
|
|
guint16 subcommand = UPDATE_EXTRA_CMD_STAY_IN_RO;
|
|
guint8 command_body[2]; /* Max command body size. */
|
|
gsize command_body_size = 0;
|
|
START_RESP start_resp;
|
|
|
|
fu_device_remove_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_REBOOTING_TO_RO);
|
|
if (!fu_cros_ec_usb_device_send_subcommand (device, subcommand, command_body,
|
|
command_body_size, &response,
|
|
&response_size, FALSE, error)) {
|
|
g_prefix_error (error, "failed to send stay-in-ro subcommand: ");
|
|
return FALSE;
|
|
}
|
|
|
|
/* flush all data from endpoint to recover in case of error */
|
|
if (!fu_cros_ec_usb_device_recovery (device, error)) {
|
|
g_prefix_error (error, "failed to flush device to idle state: ");
|
|
return FALSE;
|
|
}
|
|
|
|
/* send start request */
|
|
if (!fu_device_retry (device, fu_cros_ec_usb_device_start_request,
|
|
SETUP_RETRY_CNT, &start_resp, error)) {
|
|
g_prefix_error (error, "failed to send start request: ");
|
|
return FALSE;
|
|
}
|
|
}
|
|
|
|
if (fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN) && self->in_bootloader) {
|
|
/*
|
|
* We had previously written to the rw region (while we were
|
|
* booted from ro region), but somehow landed in ro again after
|
|
* a reboot. Since we wrote rw already, we wanted to jump
|
|
* to the new rw so we could evaluate ro.
|
|
*
|
|
* This is a transitory state due to the fact that we have to
|
|
* boot through RO to get to RW. Set another write required to
|
|
* allow the RO region to auto-jump to RW.
|
|
*
|
|
* Special flow: write phase skips actual write -> attach skips
|
|
* send of reset command, just sets wait for replug, and
|
|
* device restart status.
|
|
*/
|
|
fu_device_add_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_SPECIAL);
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_ANOTHER_WRITE_REQUIRED);
|
|
return TRUE;
|
|
}
|
|
|
|
sections = fu_cros_ec_firmware_get_sections (cros_ec_firmware);
|
|
if (sections == NULL) {
|
|
g_set_error_literal (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_INVALID_DATA,
|
|
"invalid sections");
|
|
return FALSE;
|
|
}
|
|
|
|
fu_device_set_status (device, FWUPD_STATUS_DEVICE_WRITE);
|
|
for (guint i = 0; i < sections->len; i++) {
|
|
FuCrosEcFirmwareSection *section = g_ptr_array_index (sections, i);
|
|
|
|
if (section->ustatus == FU_CROS_EC_FW_NEEDED) {
|
|
g_autoptr(GError) error_local = NULL;
|
|
|
|
if (!fu_cros_ec_usb_device_transfer_section (device,
|
|
firmware,
|
|
section,
|
|
&error_local)) {
|
|
if (g_error_matches (error_local,
|
|
G_USB_DEVICE_ERROR,
|
|
G_USB_DEVICE_ERROR_NOT_SUPPORTED)) {
|
|
g_debug ("failed to transfer section, trying another write, ignoring error: %s",
|
|
error_local->message);
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_ANOTHER_WRITE_REQUIRED);
|
|
return TRUE;
|
|
}
|
|
g_propagate_error (error, g_steal_pointer (&error_local));
|
|
return FALSE;
|
|
}
|
|
num_txed_sections++;
|
|
|
|
if (self->in_bootloader) {
|
|
fu_device_set_version (FU_DEVICE (device),
|
|
section->version.triplet);
|
|
} else {
|
|
fu_device_set_version_bootloader (FU_DEVICE (device),
|
|
section->version.triplet);
|
|
}
|
|
}
|
|
}
|
|
/* send done */
|
|
fu_cros_ec_usb_device_send_done (device);
|
|
|
|
if (num_txed_sections == 0) {
|
|
g_set_error_literal (error,
|
|
G_IO_ERROR,
|
|
G_IO_ERROR_INVALID_DATA,
|
|
"no sections transferred");
|
|
return FALSE;
|
|
}
|
|
|
|
if (self->in_bootloader)
|
|
fu_device_add_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN);
|
|
else
|
|
fu_device_add_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN);
|
|
|
|
/* logical XOR */
|
|
if (fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN) !=
|
|
fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN))
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_ANOTHER_WRITE_REQUIRED);
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_close (FuDevice *device, GError **error)
|
|
{
|
|
GUsbDevice *usb_device = fu_usb_device_get_dev (FU_USB_DEVICE (device));
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
|
|
if (!g_usb_device_release_interface (usb_device, self->iface_idx,
|
|
G_USB_DEVICE_CLAIM_INTERFACE_BIND_KERNEL_DRIVER,
|
|
error)) {
|
|
g_prefix_error (error, "failed to release interface: ");
|
|
return FALSE;
|
|
}
|
|
|
|
/* FuUsbDevice->close */
|
|
return FU_DEVICE_CLASS (fu_cros_ec_usb_device_parent_class)->close (device, error);
|
|
}
|
|
|
|
static FuFirmware *
|
|
fu_cros_ec_usb_device_prepare_firmware (FuDevice *device,
|
|
GBytes *fw,
|
|
FwupdInstallFlags flags,
|
|
GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
FuCrosEcFirmware *cros_ec_firmware = NULL;
|
|
g_autoptr(FuFirmware) firmware = fu_cros_ec_firmware_new ();
|
|
|
|
if (!fu_firmware_parse (firmware, fw, flags, error))
|
|
return NULL;
|
|
cros_ec_firmware = FU_CROS_EC_FIRMWARE (firmware);
|
|
|
|
/* pick sections */
|
|
if (!fu_cros_ec_firmware_pick_sections (cros_ec_firmware,
|
|
self->writeable_offset,
|
|
error)) {
|
|
g_prefix_error (error, "failed to pick sections: ");
|
|
return NULL;
|
|
}
|
|
return g_steal_pointer (&firmware);
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_attach (FuDevice *device, GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
|
|
fu_device_set_remove_delay (device, CROS_EC_REMOVE_DELAY_RE_ENUMERATE);
|
|
if (self->in_bootloader &&
|
|
fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_SPECIAL)) {
|
|
|
|
/*
|
|
* attach after the SPECIAL flag was set. The EC will auto-jump
|
|
* from ro -> rw, so we do not need to send an explicit
|
|
* reset_to_ro. We just need to set for another wait for replug
|
|
* as a detach + reenumeration is expected as we jump from
|
|
* ro -> rw.
|
|
*/
|
|
fu_device_remove_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_SPECIAL);
|
|
fu_device_set_status (device, FWUPD_STATUS_DEVICE_RESTART);
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_WAIT_FOR_REPLUG);
|
|
return TRUE;
|
|
}
|
|
|
|
if (fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN) &&
|
|
!fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN)) {
|
|
if (!fu_cros_ec_usb_device_reset_to_ro (device, error)) {
|
|
return FALSE;
|
|
}
|
|
} else {
|
|
fu_cros_ec_usb_device_jump_to_rw (device);
|
|
}
|
|
fu_device_set_status (device, FWUPD_STATUS_DEVICE_RESTART);
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_WAIT_FOR_REPLUG);
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static gboolean
|
|
fu_cros_ec_usb_device_detach (FuDevice *device, GError **error)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
|
|
if (fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN) &&
|
|
!fu_device_has_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN))
|
|
return TRUE;
|
|
|
|
if (self->in_bootloader) {
|
|
g_debug ("skipping immediate reboot in case of already in bootloader");
|
|
/* in RO so skip reboot */
|
|
return TRUE;
|
|
} else if (self->targ.common.flash_protection != 0x0) {
|
|
/* in RW, and RO region is write protected, so jump to RO */
|
|
fu_device_add_private_flag (device, FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN);
|
|
fu_device_set_remove_delay (device, CROS_EC_REMOVE_DELAY_RE_ENUMERATE);
|
|
if (!fu_cros_ec_usb_device_reset_to_ro (device, error))
|
|
return FALSE;
|
|
|
|
fu_device_set_status (device, FWUPD_STATUS_DEVICE_RESTART);
|
|
fu_device_add_flag (device, FWUPD_DEVICE_FLAG_WAIT_FOR_REPLUG);
|
|
}
|
|
|
|
/* success */
|
|
return TRUE;
|
|
}
|
|
|
|
static void
|
|
fu_cros_ec_usb_device_init (FuCrosEcUsbDevice *self)
|
|
{
|
|
fu_device_add_protocol (FU_DEVICE (self), "com.google.usb.crosec");
|
|
fu_device_add_flag (FU_DEVICE (self), FWUPD_DEVICE_FLAG_UPDATABLE);
|
|
fu_device_add_internal_flag (FU_DEVICE (self), FU_DEVICE_INTERNAL_FLAG_REPLUG_MATCH_GUID);
|
|
fu_device_set_version_format (FU_DEVICE (self), FWUPD_VERSION_FORMAT_TRIPLET);
|
|
fu_device_add_flag (FU_DEVICE (self), FWUPD_DEVICE_FLAG_DUAL_IMAGE);
|
|
fu_device_register_private_flag (FU_DEVICE (self),
|
|
FU_CROS_EC_USB_DEVICE_FLAG_RO_WRITTEN,
|
|
"ro-written");
|
|
fu_device_register_private_flag (FU_DEVICE (self),
|
|
FU_CROS_EC_USB_DEVICE_FLAG_RW_WRITTEN,
|
|
"rw-written");
|
|
fu_device_register_private_flag (FU_DEVICE (self),
|
|
FU_CROS_EC_USB_DEVICE_FLAG_REBOOTING_TO_RO,
|
|
"rebooting-to-ro");
|
|
fu_device_register_private_flag (FU_DEVICE (self),
|
|
FU_CROS_EC_USB_DEVICE_FLAG_SPECIAL,
|
|
"special");
|
|
}
|
|
|
|
static void
|
|
fu_cros_ec_usb_device_to_string (FuDevice *device, guint idt, GString *str)
|
|
{
|
|
FuCrosEcUsbDevice *self = FU_CROS_EC_USB_DEVICE (device);
|
|
g_autofree gchar *min_rollback = NULL;
|
|
|
|
fu_common_string_append_kv (str, idt, "GitHash", self->version.sha1);
|
|
fu_common_string_append_kb (str, idt, "Dirty",
|
|
self->version.dirty);
|
|
fu_common_string_append_ku (str, idt, "ProtocolVersion",
|
|
self->protocol_version);
|
|
fu_common_string_append_ku (str, idt, "HeaderType",
|
|
self->header_type);
|
|
fu_common_string_append_ku (str, idt, "MaxPDUSize",
|
|
self->targ.common.maximum_pdu_size);
|
|
fu_common_string_append_kx (str, idt, "FlashProtectionStatus",
|
|
self->targ.common.flash_protection);
|
|
fu_common_string_append_kv (str, idt, "RawVersion",
|
|
self->targ.common.version);
|
|
fu_common_string_append_ku (str, idt, "KeyVersion",
|
|
self->targ.common.key_version);
|
|
min_rollback = g_strdup_printf ("%" G_GINT32_FORMAT,
|
|
self->targ.common.min_rollback);
|
|
fu_common_string_append_kv (str, idt, "MinRollback", min_rollback);
|
|
fu_common_string_append_kx (str, idt, "WriteableOffset",
|
|
self->writeable_offset);
|
|
}
|
|
|
|
static void
|
|
fu_cros_ec_usb_device_class_init (FuCrosEcUsbDeviceClass *klass)
|
|
{
|
|
FuDeviceClass *klass_device = FU_DEVICE_CLASS (klass);
|
|
klass_device->attach = fu_cros_ec_usb_device_attach;
|
|
klass_device->detach = fu_cros_ec_usb_device_detach;
|
|
klass_device->prepare_firmware = fu_cros_ec_usb_device_prepare_firmware;
|
|
klass_device->setup = fu_cros_ec_usb_device_setup;
|
|
klass_device->to_string = fu_cros_ec_usb_device_to_string;
|
|
klass_device->write_firmware = fu_cros_ec_usb_device_write_firmware;
|
|
klass_device->open = fu_cros_ec_usb_device_open;
|
|
klass_device->probe = fu_cros_ec_usb_device_probe;
|
|
klass_device->close = fu_cros_ec_usb_device_close;
|
|
}
|