diff --git a/src/utils/check-pyroscope-config.ts b/src/utils/check-pyroscope-config.ts index 7945f5c2..7f6ae590 100644 --- a/src/utils/check-pyroscope-config.ts +++ b/src/utils/check-pyroscope-config.ts @@ -4,6 +4,9 @@ import { PyroscopeWallConfig, } from '../pyroscope-config.js'; +const INVALID_LABEL_CHARACTERS = ['{', '}', ',', '='] as const; +const INVALID_LABEL_CHARACTERS_MESSAGE = "'{', '}', ',', or '=' characters"; + export function checkPyroscopeConfig( config: unknown ): asserts config is PyroscopeConfig { @@ -15,8 +18,12 @@ export function checkPyroscopeConfig( if (!hasValidApplicationName(config)) { errors.push('Expecting a config with string appName'); + } else { + validateApplicationName(config, errors); } + validateTags(config, errors); + if (!hasValidAuthToken(config)) { errors.push('Expecting a config with string auth token'); } @@ -51,6 +58,66 @@ function hasValidApplicationName( ); } +function validateApplicationName( + config: Record, + errors: string[] +): void { + const appName: string | undefined = (config as Partial) + .appName; + + if (appName === undefined) { + return; + } + + if (hasInvalidLabelCharacters(appName)) { + errors.push(`appName must not contain ${INVALID_LABEL_CHARACTERS_MESSAGE}`); + } +} + +function validateTags( + config: Record, + errors: string[] +): void { + const tags: unknown = (config as Partial).tags; + + if (tags === undefined) { + return; + } + + if (!isObject(tags)) { + errors.push('Expecting a config with object tags'); + return; + } + + for (const [tagKey, tagValue] of Object.entries(tags)) { + if (hasInvalidLabelCharacters(tagKey)) { + errors.push( + `tag key "${tagKey}" must not contain ${INVALID_LABEL_CHARACTERS_MESSAGE}` + ); + } + + if (typeof tagValue === 'string' && hasInvalidLabelCharacters(tagValue)) { + errors.push( + `tag value "${tagValue}" for key "${tagKey}" must not contain ${INVALID_LABEL_CHARACTERS_MESSAGE}` + ); + } else if (typeof tagValue !== 'number' && typeof tagValue !== 'string') { + errors.push( + `tag value "${String(tagValue)}" for key "${tagKey}" must be a string or number` + ); + } + } +} + +function hasInvalidLabelCharacters(value: string): boolean { + for (const character of INVALID_LABEL_CHARACTERS) { + if (value.includes(character)) { + return true; + } + } + + return false; +} + function hasValidAuthToken(config: Record): boolean { return ( (config as Partial).authToken === undefined || diff --git a/test/check-pyroscope-config.test.ts b/test/check-pyroscope-config.test.ts new file mode 100644 index 00000000..a880f0fe --- /dev/null +++ b/test/check-pyroscope-config.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { checkPyroscopeConfig } from '../src/utils/check-pyroscope-config.js'; + +const INVALID_CHARACTERS = ['{', '}', ',', '=']; + +describe('checkPyroscopeConfig', () => { + it('accepts a valid config', () => { + assert.doesNotThrow(() => + checkPyroscopeConfig({ + appName: 'my-app', + tags: { region: 'us-east-1', replica: 3 }, + }) + ); + }); + + for (const character of INVALID_CHARACTERS) { + it(`rejects appName containing "${character}"`, () => { + assert.throws( + () => checkPyroscopeConfig({ appName: `my${character}app` }), + /appName must not contain/ + ); + }); + + it(`rejects tag key containing "${character}"`, () => { + assert.throws( + () => + checkPyroscopeConfig({ + appName: 'my-app', + tags: { [`bad${character}key`]: 'value' }, + }), + new RegExp(`tag key "bad\\${character}key" must not contain`) + ); + }); + + it(`rejects tag value containing "${character}"`, () => { + assert.throws( + () => + checkPyroscopeConfig({ + appName: 'my-app', + tags: { key: `bad${character}value` }, + }), + new RegExp(`tag value "bad\\${character}value" for key "key"`) + ); + }); + } + + it('rejects a tag value that is not a string or number', () => { + assert.throws( + () => + checkPyroscopeConfig({ + appName: 'my-app', + tags: { key: true }, + }), + /tag value "true" for key "key" must be a string or number/ + ); + }); +});