diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..d7568adf6 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 20.11.1 diff --git a/jest.config.js b/jest.config.js index 228ed1f67..19fd30d22 100644 --- a/jest.config.js +++ b/jest.config.js @@ -46,11 +46,15 @@ module.exports = { { displayName: 'delivery-fetch', testMatch: ['/packages/delivery-fetch/**/*.test.ts'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest/setup/crypto.ts'], ...defaultModuleConfig }, { displayName: 'browser', testMatch: ['/packages/platforms/browser/**/*.test.ts'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest/setup/crypto.ts'], ...defaultModuleConfig }, { diff --git a/jest/setup/crypto.ts b/jest/setup/crypto.ts new file mode 100644 index 000000000..659f09583 --- /dev/null +++ b/jest/setup/crypto.ts @@ -0,0 +1,19 @@ +import { TextDecoder, TextEncoder } from 'node:util' +import crypto from 'crypto' + +Object.defineProperty(window, 'crypto', { + get () { + return { + getRandomValues: crypto.getRandomValues, + subtle: crypto.webcrypto.subtle + } + } +}) + +Object.defineProperty(window, 'TextEncoder', { + get () { return TextEncoder } +}) + +Object.defineProperty(window, 'TextDecoder', { + get () { return TextDecoder } +}) diff --git a/packages/core/lib/config.ts b/packages/core/lib/config.ts index ebf3abca7..a6fd24de5 100644 --- a/packages/core/lib/config.ts +++ b/packages/core/lib/config.ts @@ -8,7 +8,7 @@ import { ATTRIBUTE_STRING_VALUE_LIMIT_MAX } from './custom-attribute-limits' import type { Plugin } from './plugin' -import { isLogger, isNumber, isObject, isOnSpanEndCallbacks, isPluginArray, isString, isStringArray, isStringWithLength } from './validation' +import { isBoolean, isLogger, isNumber, isObject, isOnSpanEndCallbacks, isPluginArray, isString, isStringArray, isStringWithLength } from './validation' type SetTraceCorrelation = (traceId: string, spanId: string) => void @@ -53,6 +53,7 @@ export interface Configuration { attributeStringValueLimit?: number attributeArrayLengthLimit?: number attributeCountLimit?: number + sendPayloadChecksums?: boolean } export interface TestConfiguration { @@ -81,6 +82,7 @@ export interface CoreSchema extends Schema { plugins: ConfigOption>> bugsnag: ConfigOption samplingProbability: ConfigOption + sendPayloadChecksums: ConfigOption } export const schema: CoreSchema = { @@ -153,6 +155,11 @@ export const schema: CoreSchema = { defaultValue: ATTRIBUTE_COUNT_LIMIT_DEFAULT, message: `should be a number between 1 and ${ATTRIBUTE_COUNT_LIMIT_MAX}`, validate: (value: unknown): value is number => isNumber(value) && value > 0 && value <= ATTRIBUTE_COUNT_LIMIT_MAX + }, + sendPayloadChecksums: { + defaultValue: false, + message: 'should be true|false', + validate: isBoolean } } diff --git a/packages/core/lib/core.ts b/packages/core/lib/core.ts index f259e35ea..215618e5a 100644 --- a/packages/core/lib/core.ts +++ b/packages/core/lib/core.ts @@ -73,6 +73,11 @@ export function createClient ( start: (config: C | string) => { const configuration = validateConfig(config, options.schema) + // sendPayloadChecksums is false by default unless custom endpoints are not specified + if (typeof config !== 'string' && !config.endpoint) { + configuration.sendPayloadChecksums = ('sendPayloadChecksums' in config && config.sendPayloadChecksums) || true + } + // if using the default endpoint add the API key as a subdomain // e.g. convert URL https://otlp.bugsnag.com/v1/traces to URL https://.otlp.bugsnag.com/v1/traces if (configuration.endpoint === schema.endpoint.defaultValue) { @@ -92,7 +97,7 @@ export function createClient ( } } - const delivery = options.deliveryFactory(configuration.endpoint) + const delivery = options.deliveryFactory(configuration.endpoint, configuration.sendPayloadChecksums) options.spanAttributesSource.configure(configuration) diff --git a/packages/core/lib/delivery.ts b/packages/core/lib/delivery.ts index 1bdecf39d..036f4a25a 100644 --- a/packages/core/lib/delivery.ts +++ b/packages/core/lib/delivery.ts @@ -5,7 +5,7 @@ import type { JsonEvent } from './events' import type { Kind, SpanEnded } from './span' import { spanToJson } from './span' -export type DeliveryFactory = (endpoint: string) => Delivery +export type DeliveryFactory = (endpoint: string, sendPayloadChecksums: boolean) => Delivery export type ResponseState = 'success' | 'failure-discard' | 'failure-retryable' @@ -60,6 +60,8 @@ export interface TracePayload { // therefore it's 'undefined' when passed to delivery, which adds a value // immediately before initiating the request 'Bugsnag-Sent-At'?: string + // 'undefined' when passed to delivery, which adds a value before initiating the request + 'Bugsnag-Integrity'?: string } } diff --git a/packages/core/tests/core.test.ts b/packages/core/tests/core.test.ts index 258c915ae..3a20d196c 100644 --- a/packages/core/tests/core.test.ts +++ b/packages/core/tests/core.test.ts @@ -342,7 +342,7 @@ describe('Core', () => { await jest.runOnlyPendingTimersAsync() - expect(deliveryFactory).toHaveBeenCalledWith('https://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.otlp.bugsnag.com/v1/traces') + expect(deliveryFactory).toHaveBeenCalledWith('https://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.otlp.bugsnag.com/v1/traces', false) }) }) @@ -356,7 +356,7 @@ describe('Core', () => { await jest.runOnlyPendingTimersAsync() - expect(deliveryFactory).toHaveBeenCalledWith('https://my-custom-otel-repeater.com') + expect(deliveryFactory).toHaveBeenCalledWith('https://my-custom-otel-repeater.com', false) }) }) }) diff --git a/packages/delivery-fetch/lib/delivery.ts b/packages/delivery-fetch/lib/delivery.ts index 69a638b27..600ac3b11 100644 --- a/packages/delivery-fetch/lib/delivery.ts +++ b/packages/delivery-fetch/lib/delivery.ts @@ -1,7 +1,4 @@ -import { - - responseStateFromStatusCode -} from '@bugsnag/core-performance' +import { responseStateFromStatusCode } from '@bugsnag/core-performance' import type { BackgroundingListener, Clock, Delivery, DeliveryFactory, TracePayload } from '@bugsnag/core-performance' export type Fetch = typeof fetch @@ -40,7 +37,7 @@ function createFetchDeliveryFactory ( }) } - return function fetchDeliveryFactory (endpoint: string): Delivery { + return function fetchDeliveryFactory (endpoint: string, sendPayloadChecksums?: boolean): Delivery { return { async send (payload: TracePayload) { const body = JSON.stringify(payload.body) @@ -48,6 +45,12 @@ function createFetchDeliveryFactory ( payload.headers['Bugsnag-Sent-At'] = clock.date().toISOString() try { + const integrityHeaderValue = await getIntegrityHeaderValue(sendPayloadChecksums ?? false, window, body) + + if (integrityHeaderValue) { + payload.headers['Bugsnag-Integrity'] = integrityHeaderValue + } + const response = await fetch(endpoint, { method: 'POST', keepalive, @@ -72,3 +75,19 @@ function createFetchDeliveryFactory ( } export default createFetchDeliveryFactory + +function getIntegrityHeaderValue (sendPayloadChecksums: boolean, windowOrWorkerGlobalScope: Window, requestBody: string) { + if (sendPayloadChecksums && windowOrWorkerGlobalScope.isSecureContext && windowOrWorkerGlobalScope.crypto && windowOrWorkerGlobalScope.crypto.subtle && windowOrWorkerGlobalScope.crypto.subtle.digest && typeof TextEncoder === 'function') { + const msgUint8 = new TextEncoder().encode(requestBody) + return windowOrWorkerGlobalScope.crypto.subtle.digest('SHA-1', msgUint8).then((hashBuffer) => { + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + + return 'sha1 ' + hashHex + }) + } + + return Promise.resolve() +} diff --git a/packages/delivery-fetch/tests/delivery.test.ts b/packages/delivery-fetch/tests/delivery.test.ts index be8e64687..89a37e709 100644 --- a/packages/delivery-fetch/tests/delivery.test.ts +++ b/packages/delivery-fetch/tests/delivery.test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment jsdom - */ - import type { TracePayload } from '@bugsnag/core-performance' import type { JsonEvent } from '@bugsnag/core-performance/lib' import { @@ -14,11 +10,15 @@ import createFetchDeliveryFactory from '../lib/delivery' const SENT_AT_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ describe('Browser Delivery', () => { - it('delivers a span', async () => { - const fetch = jest.fn(() => Promise.resolve({ status: 200, headers: new Headers() } as unknown as Response)) - const backgroundingListener = new ControllableBackgroundingListener() - const clock = new IncrementingClock('2023-01-02T00:00:00.000Z') + beforeAll(() => { + window.isSecureContext = true + }) + + afterAll(() => { + window.isSecureContext = false + }) + it('delivers a span', async () => { const deliveryPayload: TracePayload = { body: { resourceSpans: [{ @@ -45,8 +45,12 @@ describe('Browser Delivery', () => { } } + const fetch = jest.fn(() => Promise.resolve({ status: 200, headers: new Headers() } as unknown as Response)) + const backgroundingListener = new ControllableBackgroundingListener() + const clock = new IncrementingClock('2023-01-02T00:00:00.000Z') + const deliveryFactory = createFetchDeliveryFactory(fetch, clock, backgroundingListener) - const delivery = deliveryFactory('/test') + const delivery = deliveryFactory('/test', true) const response = await delivery.send(deliveryPayload) expect(fetch).toHaveBeenCalledWith('/test', { @@ -57,7 +61,8 @@ describe('Browser Delivery', () => { 'Bugsnag-Api-Key': 'test-api-key', 'Bugsnag-Span-Sampling': '1:1', 'Content-Type': 'application/json', - 'Bugsnag-Sent-At': new Date(clock.timeOrigin + 1).toISOString() + 'Bugsnag-Sent-At': new Date(clock.timeOrigin + 1).toISOString(), + 'Bugsnag-Integrity': 'sha1 835f1ff603eb1be3bf91fc9716c0841e1b37ee64' } }) @@ -98,7 +103,7 @@ describe('Browser Delivery', () => { } const deliveryFactory = createFetchDeliveryFactory(fetch, new IncrementingClock(), backgroundingListener) - const delivery = deliveryFactory('/test') + const delivery = deliveryFactory('/test', false) backgroundingListener.sendToBackground() @@ -153,7 +158,7 @@ describe('Browser Delivery', () => { } const deliveryFactory = createFetchDeliveryFactory(fetch, new IncrementingClock(), backgroundingListener) - const delivery = deliveryFactory('/test') + const delivery = deliveryFactory('/test', false) backgroundingListener.sendToBackground() backgroundingListener.sendToForeground() @@ -200,7 +205,7 @@ describe('Browser Delivery', () => { const backgroundingListener = new ControllableBackgroundingListener() const deliveryFactory = createFetchDeliveryFactory(fetch, new IncrementingClock(), backgroundingListener) - const delivery = deliveryFactory('/test') + const delivery = deliveryFactory('/test', false) const deliveryPayload: TracePayload = { body: { resourceSpans: [] }, headers: { @@ -238,7 +243,7 @@ describe('Browser Delivery', () => { const backgroundingListener = new ControllableBackgroundingListener() const deliveryFactory = createFetchDeliveryFactory(fetch, new IncrementingClock(), backgroundingListener) - const delivery = deliveryFactory('/test') + const delivery = deliveryFactory('/test', false) const payload: TracePayload = { body: { resourceSpans: [] }, headers: { @@ -293,10 +298,119 @@ describe('Browser Delivery', () => { } const deliveryFactory = createFetchDeliveryFactory(fetch, clock, backgroundingListener) - const delivery = deliveryFactory('/test') + const delivery = deliveryFactory('/test', false) const { state } = await delivery.send(deliveryPayload) expect(state).toBe('failure-discard') }) + + it('omits the bugsnag integrity header when not in a secure context', async () => { + const deliveryPayload: TracePayload = { + body: { + resourceSpans: [{ + resource: { attributes: [{ key: 'test-key', value: { stringValue: 'test-value' } }] }, + scopeSpans: [{ + spans: [{ + name: 'test-span', + kind: 1, + spanId: 'test-span-id', + traceId: 'test-trace-id', + endTimeUnixNano: '56789', + startTimeUnixNano: '12345', + attributes: [{ key: 'test-span', value: { intValue: '12345' } }], + droppedAttributesCount: 0, + events: [] + }] + }] + }] + }, + headers: { + 'Bugsnag-Api-Key': 'test-api-key', + 'Content-Type': 'application/json', + 'Bugsnag-Span-Sampling': '1:1' + } + } + + window.isSecureContext = false + const _fetch = jest.fn(() => Promise.resolve({ status: 200, headers: new Headers() } as unknown as Response)) + const backgroundingListener = new ControllableBackgroundingListener() + const clock = new IncrementingClock('2023-01-02T00:00:00.000Z') + + const deliveryFactory = createFetchDeliveryFactory(_fetch, clock, backgroundingListener) + const delivery = deliveryFactory('/test', true) + const response = await delivery.send(deliveryPayload) + + expect(_fetch).toHaveBeenCalledWith('/test', { + method: 'POST', + keepalive: false, + body: JSON.stringify(deliveryPayload.body), + headers: { + 'Bugsnag-Api-Key': 'test-api-key', + 'Bugsnag-Span-Sampling': '1:1', + 'Content-Type': 'application/json', + 'Bugsnag-Sent-At': new Date(clock.timeOrigin + 1).toISOString() + } + }) + + expect(response).toStrictEqual({ + state: 'success', + samplingProbability: undefined + }) + + window.isSecureContext = true + }) + + it('omits the bugsnag integrity header when sendPayloadChecksums is false', async () => { + const deliveryPayload: TracePayload = { + body: { + resourceSpans: [{ + resource: { attributes: [{ key: 'test-key', value: { stringValue: 'test-value' } }] }, + scopeSpans: [{ + spans: [{ + name: 'test-span', + kind: 1, + spanId: 'test-span-id', + traceId: 'test-trace-id', + endTimeUnixNano: '56789', + startTimeUnixNano: '12345', + attributes: [{ key: 'test-span', value: { intValue: '12345' } }], + droppedAttributesCount: 0, + events: [] + }] + }] + }] + }, + headers: { + 'Bugsnag-Api-Key': 'test-api-key', + 'Content-Type': 'application/json', + 'Bugsnag-Span-Sampling': '1:1' + } + } + + const fetch = jest.fn(() => Promise.resolve({ status: 200, headers: new Headers() } as unknown as Response)) + const backgroundingListener = new ControllableBackgroundingListener() + const clock = new IncrementingClock('2023-01-02T00:00:00.000Z') + + const deliveryFactory = createFetchDeliveryFactory(fetch, clock, backgroundingListener) + const delivery = deliveryFactory('/test', false) + const response = await delivery.send(deliveryPayload) + + expect(fetch).toHaveBeenCalledWith('/test', { + method: 'POST', + keepalive: false, + body: JSON.stringify(deliveryPayload.body), + headers: { + 'Bugsnag-Api-Key': 'test-api-key', + 'Bugsnag-Span-Sampling': '1:1', + 'Content-Type': 'application/json', + 'Bugsnag-Sent-At': new Date(clock.timeOrigin + 1).toISOString() + } + }) + + expect(response).toStrictEqual({ + state: 'success', + samplingProbability: undefined + }) + }) }) diff --git a/packages/platforms/browser/tests/browser.test.ts b/packages/platforms/browser/tests/browser.test.ts index 8deab3243..502fbf193 100644 --- a/packages/platforms/browser/tests/browser.test.ts +++ b/packages/platforms/browser/tests/browser.test.ts @@ -319,4 +319,73 @@ describe('Browser client integration tests', () => { await jest.runOnlyPendingTimersAsync() }) }) + + describe('payload checksum behavior (Bugsnag-Integrity header)', () => { + beforeAll(() => { + // eslint-disable-next-line compat/compat + window.isSecureContext = true + }) + + afterAll(() => { + // eslint-disable-next-line compat/compat + window.isSecureContext = false + }) + + it('includes the integrity header by default', async () => { + client.start({ + apiKey: VALID_API_KEY, + autoInstrumentFullPageLoads: false, + autoInstrumentNetworkRequests: false, + autoInstrumentRouteChanges: false + }) + + setNextSamplingProbability(0.2) + createSpans(100) + await jest.advanceTimersByTimeAsync(RESPONSE_TIME + 1) + expect(mockFetch).toHaveBeenCalledTimes(2) + + expect(mockFetch.mock.calls[1][1]).toEqual(expect.objectContaining({ + headers: expect.objectContaining({ 'Bugsnag-Integrity': expect.stringMatching(/^sha1 (\d|[abcdef]){40}$/) }) + })) + }) + + it('does not include the integrity header if endpoint configuration is supplied', async () => { + client.start({ + apiKey: VALID_API_KEY, + endpoint: '/test', + autoInstrumentFullPageLoads: false, + autoInstrumentNetworkRequests: false, + autoInstrumentRouteChanges: false + }) + + setNextSamplingProbability(0.2) + createSpans(100) + await jest.advanceTimersByTimeAsync(RESPONSE_TIME + 1) + expect(mockFetch).toHaveBeenCalledTimes(2) + + expect(mockFetch.mock.calls[1][1]).toEqual(expect.objectContaining({ + headers: expect.not.objectContaining({ 'Bugsnag-Integrity': expect.any(String) }) + })) + }) + + it('can be enabled for a custom endpoint configuration by using sendPayloadChecksums', async () => { + client.start({ + apiKey: VALID_API_KEY, + endpoint: '/test', + sendPayloadChecksums: true, + autoInstrumentFullPageLoads: false, + autoInstrumentNetworkRequests: false, + autoInstrumentRouteChanges: false + }) + + setNextSamplingProbability(0.2) + createSpans(100) + await jest.advanceTimersByTimeAsync(RESPONSE_TIME + 1) + expect(mockFetch).toHaveBeenCalledTimes(2) + + expect(mockFetch.mock.calls[1][1]).toEqual(expect.objectContaining({ + headers: expect.objectContaining({ 'Bugsnag-Integrity': expect.stringMatching(/^sha1 (\d|[abcdef]){40}$/) }) + })) + }) + }) }) diff --git a/test/browser/features/fixtures/packages/integrity-disabled/index.html b/test/browser/features/fixtures/packages/integrity-disabled/index.html new file mode 100644 index 000000000..65e6364f8 --- /dev/null +++ b/test/browser/features/fixtures/packages/integrity-disabled/index.html @@ -0,0 +1,13 @@ + + + + + + + Integrity header + + +

integrity-header

+ + + diff --git a/test/browser/features/fixtures/packages/integrity-disabled/package.json b/test/browser/features/fixtures/packages/integrity-disabled/package.json new file mode 100644 index 000000000..62faf341f --- /dev/null +++ b/test/browser/features/fixtures/packages/integrity-disabled/package.json @@ -0,0 +1,8 @@ +{ + "name": "integrity-disabled", + "private": true, + "scripts": { + "build": "rollup --config ../rollup.config.mjs", + "clean": "rm -rf dist" + } +} diff --git a/test/browser/features/fixtures/packages/integrity-disabled/src/index.js b/test/browser/features/fixtures/packages/integrity-disabled/src/index.js new file mode 100644 index 000000000..13a2f8c8a --- /dev/null +++ b/test/browser/features/fixtures/packages/integrity-disabled/src/index.js @@ -0,0 +1,28 @@ +import BugsnagPerformance from '@bugsnag/browser-performance' + +const parameters = new URLSearchParams(window.location.search) +const apiKey = parameters.get('api_key') +const endpoint = parameters.get('endpoint') + +BugsnagPerformance.start({ + apiKey, + endpoint, + sendPayloadChecksums: false, + maximumBatchSize: 1, + autoInstrumentFullPageLoads: false, + autoInstrumentNetworkRequests: false, + autoInstrumentRouteChanges: false, + serviceName: 'integrity' +}) + +document.getElementById('send-span').onclick = () => { + const spanOptions = {} + + if (parameters.has('isFirstClass')) { + spanOptions.isFirstClass = JSON.parse(parameters.get('isFirstClass')) + } + + const span = BugsnagPerformance.startSpan("Custom/ManualSpanScenario", spanOptions) + span.end() +} + diff --git a/test/browser/features/fixtures/packages/integrity/index.html b/test/browser/features/fixtures/packages/integrity/index.html new file mode 100644 index 000000000..65e6364f8 --- /dev/null +++ b/test/browser/features/fixtures/packages/integrity/index.html @@ -0,0 +1,13 @@ + + + + + + + Integrity header + + +

integrity-header

+ + + diff --git a/test/browser/features/fixtures/packages/integrity/package.json b/test/browser/features/fixtures/packages/integrity/package.json new file mode 100644 index 000000000..2c29397f5 --- /dev/null +++ b/test/browser/features/fixtures/packages/integrity/package.json @@ -0,0 +1,8 @@ +{ + "name": "integrity", + "private": true, + "scripts": { + "build": "rollup --config ../rollup.config.mjs", + "clean": "rm -rf dist" + } +} diff --git a/test/browser/features/fixtures/packages/integrity/src/index.js b/test/browser/features/fixtures/packages/integrity/src/index.js new file mode 100644 index 000000000..dab93ae96 --- /dev/null +++ b/test/browser/features/fixtures/packages/integrity/src/index.js @@ -0,0 +1,28 @@ +import BugsnagPerformance from '@bugsnag/browser-performance' + +const parameters = new URLSearchParams(window.location.search) +const apiKey = parameters.get('api_key') +const endpoint = parameters.get('endpoint') + +BugsnagPerformance.start({ + apiKey, + endpoint, + sendPayloadChecksums: true, + maximumBatchSize: 1, + autoInstrumentFullPageLoads: false, + autoInstrumentNetworkRequests: false, + autoInstrumentRouteChanges: false, + serviceName: 'integrity' +}) + +document.getElementById('send-span').onclick = () => { + const spanOptions = {} + + if (parameters.has('isFirstClass')) { + spanOptions.isFirstClass = JSON.parse(parameters.get('isFirstClass')) + } + + const span = BugsnagPerformance.startSpan("Custom/ManualSpanScenario", spanOptions) + span.end() +} + diff --git a/test/browser/features/integrity.feature b/test/browser/features/integrity.feature new file mode 100644 index 000000000..8cca1ec94 --- /dev/null +++ b/test/browser/features/integrity.feature @@ -0,0 +1,20 @@ +@skip_safari_11 +Feature: Integrity header + +Scenario: Integrity headers are set when setPayloadChecksums is true + Given I navigate to the test URL "/docs/integrity" + And I wait to receive a sampling request + Then I click the element "send-span" + And I wait for 1 span + + Then the sampling request "bugsnag-integrity" header matches the regex "^sha1 (\d|[abcdef]){40}$" + Then the trace "bugsnag-integrity" header matches the regex "^sha1 (\d|[abcdef]){40}$" + +Scenario: Integrity headers are not set when setPayloadChecksums is false + Given I navigate to the test URL "/docs/integrity-disabled" + And I wait to receive a sampling request + Then I click the element "send-span" + And I wait for 1 span + + Then the sampling request "bugsnag-integrity" header is not present + Then the trace "bugsnag-integrity" header is not present \ No newline at end of file diff --git a/test/browser/features/support/maze-config.rb b/test/browser/features/support/maze-config.rb index a15b30b7b..c78fd19d9 100644 --- a/test/browser/features/support/maze-config.rb +++ b/test/browser/features/support/maze-config.rb @@ -16,11 +16,7 @@ def get_test_url() maze_address = "#{host}:9339" end - if Maze.config.https - protocol = 'https' - else - protocol = 'http' - end + protocol = Maze.config.https ? 'https' : 'http' UrlGenerator.new( URI("#{protocol}://#{maze_address}"),