[Ingest Pipelines] Fix serialization and deserialization of user input for "patterns" fields (#94689)

* updated serialization and deserialization behavior of dissect and gsub processors, also addded a test

* also fix grok processor

* pivot input checking to use JSON.stringify and JSON.parse

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2021-03-18 10:07:39 +01:00 committed by GitHub
parent 52a1ce1723
commit 628bb4b377
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 142 additions and 12 deletions

View file

@ -44,7 +44,15 @@ interface Props {
/**
* Validation to be applied to every text item
*/
textValidation?: ValidationFunc<any, string, string>;
textValidations?: Array<ValidationFunc<any, string, string>>;
/**
* Serializer to be applied to every text item
*/
textSerializer?: <O = string>(v: string) => O;
/**
* Deserializer to be applied to every text item
*/
textDeserializer?: (v: unknown) => string;
}
const i18nTexts = {
@ -63,7 +71,9 @@ function DragAndDropTextListComponent({
onAdd,
onRemove,
addLabel,
textValidation,
textValidations,
textDeserializer,
textSerializer,
}: Props): JSX.Element {
const [droppableId] = useState(() => uuid.v4());
const [firstItemId] = useState(() => uuid.v4());
@ -133,9 +143,11 @@ function DragAndDropTextListComponent({
<UseField<string>
path={item.path}
config={{
validations: textValidation
? [{ validator: textValidation }]
validations: textValidations
? textValidations.map((validator) => ({ validator }))
: undefined,
deserializer: textDeserializer,
serializer: textSerializer,
}}
readDefaultValueOnForm={!item.isNew}
>

View file

@ -22,7 +22,7 @@ import {
import { FieldNameField } from './common_fields/field_name_field';
import { IgnoreMissingField } from './common_fields/ignore_missing_field';
import { EDITOR_PX_HEIGHT, from } from './shared';
import { EDITOR_PX_HEIGHT, from, to, isJSONStringValidator } from './shared';
const { emptyField } = fieldValidators;
@ -34,6 +34,8 @@ const getFieldsConfig = (esDocUrl: string): Record<string, FieldConfig> => {
label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', {
defaultMessage: 'Pattern',
}),
deserializer: to.escapeBackslashes,
serializer: from.unescapeBackslashes,
helpText: (
<FormattedMessage
id="xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText"
@ -67,6 +69,9 @@ const getFieldsConfig = (esDocUrl: string): Record<string, FieldConfig> => {
)
),
},
{
validator: isJSONStringValidator,
},
],
},
/* Optional field config */

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { flow } from 'lodash';
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
@ -22,7 +23,7 @@ import { XJsonEditor, DragAndDropTextList } from '../field_components';
import { FieldNameField } from './common_fields/field_name_field';
import { IgnoreMissingField } from './common_fields/ignore_missing_field';
import { FieldsConfig, to, from, EDITOR_PX_HEIGHT } from './shared';
import { FieldsConfig, to, from, EDITOR_PX_HEIGHT, isJSONStringValidator } from './shared';
const { isJsonField, emptyField } = fieldValidators;
@ -46,7 +47,10 @@ const patternsValidation: ValidationFunc<any, string, ArrayItem[]> = ({ value, f
}
};
const patternValidation = emptyField(valueRequiredMessage);
const patternValidations: Array<ValidationFunc<any, string, string>> = [
emptyField(valueRequiredMessage),
isJSONStringValidator,
];
const fieldsConfig: FieldsConfig = {
/* Required field configs */
@ -54,6 +58,8 @@ const fieldsConfig: FieldsConfig = {
label: i18n.translate('xpack.ingestPipelines.pipelineEditor.grokForm.patternsFieldLabel', {
defaultMessage: 'Patterns',
}),
deserializer: flow(String, to.escapeBackslashes),
serializer: from.unescapeBackslashes,
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.grokForm.patternsHelpText', {
defaultMessage:
'Grok expressions used to match and extract named capture groups. Uses the first matching expression.',
@ -133,7 +139,9 @@ export const Grok: FunctionComponent = () => {
onAdd={addItem}
onRemove={removeItem}
addLabel={i18nTexts.addPatternLabel}
textValidation={patternValidation}
textValidations={patternValidations}
textDeserializer={fieldsConfig.patterns?.deserializer}
textSerializer={fieldsConfig.patterns?.serializer}
/>
);
}}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { flow } from 'lodash';
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
@ -12,7 +13,7 @@ import { FIELD_TYPES, fieldValidators, UseField, Field } from '../../../../../..
import { TextEditor } from '../field_components';
import { EDITOR_PX_HEIGHT, FieldsConfig } from './shared';
import { EDITOR_PX_HEIGHT, FieldsConfig, from, to, isJSONStringValidator } from './shared';
import { FieldNameField } from './common_fields/field_name_field';
import { IgnoreMissingField } from './common_fields/ignore_missing_field';
import { TargetField } from './common_fields/target_field';
@ -26,7 +27,8 @@ const fieldsConfig: FieldsConfig = {
label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldLabel', {
defaultMessage: 'Pattern',
}),
deserializer: String,
deserializer: flow(String, to.escapeBackslashes),
serializer: from.unescapeBackslashes,
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldHelpText', {
defaultMessage: 'Regular expression used to match substrings in the field.',
}),
@ -38,6 +40,9 @@ const fieldsConfig: FieldsConfig = {
})
),
},
{
validator: isJSONStringValidator,
},
],
},

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { from, to } from './shared';
describe('shared', () => {
describe('deserialization helpers', () => {
// This is the text that will be passed to the text input
test('to.escapeBackslashes', () => {
// this input loaded from the server
const input1 = 'my\ttab';
expect(to.escapeBackslashes(input1)).toBe('my\\ttab');
// this input loaded from the server
const input2 = 'my\\ttab';
expect(to.escapeBackslashes(input2)).toBe('my\\\\ttab');
// this input loaded from the server
const input3 = '\t\n\rOK';
expect(to.escapeBackslashes(input3)).toBe('\\t\\n\\rOK');
const input4 = `%{clientip} %{ident} %{auth} [%{@timestamp}] \"%{verb} %{request} HTTP/%{httpversion}\" %{status} %{size}`;
expect(to.escapeBackslashes(input4)).toBe(
'%{clientip} %{ident} %{auth} [%{@timestamp}] \\"%{verb} %{request} HTTP/%{httpversion}\\" %{status} %{size}'
);
});
});
describe('serialization helpers', () => {
test('from.unescapeBackslashes', () => {
// user typed in "my\ttab"
const input1 = 'my\\ttab';
expect(from.unescapeBackslashes(input1)).toBe('my\ttab');
// user typed in "my\\tab"
const input2 = 'my\\\\ttab';
expect(from.unescapeBackslashes(input2)).toBe('my\\ttab');
// user typed in "\t\n\rOK"
const input3 = '\\t\\n\\rOK';
expect(from.unescapeBackslashes(input3)).toBe('\t\n\rOK');
const input5 = `%{clientip} %{ident} %{auth} [%{@timestamp}] \\"%{verb} %{request} HTTP/%{httpversion}\\" %{status} %{size}`;
expect(from.unescapeBackslashes(input5)).toBe(
`%{clientip} %{ident} %{auth} [%{@timestamp}] \"%{verb} %{request} HTTP/%{httpversion}\" %{status} %{size}`
);
});
});
});

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { FunctionComponent } from 'react';
import type { FunctionComponent } from 'react';
import * as rt from 'io-ts';
import { i18n } from '@kbn/i18n';
import { isRight } from 'fp-ts/lib/Either';
import { FieldConfig } from '../../../../../../shared_imports';
import { FieldConfig, ValidationFunc } from '../../../../../../shared_imports';
export const arrayOfStrings = rt.array(rt.string);
@ -36,6 +37,17 @@ export const to = {
arrayOfStrings: (v: unknown): string[] =>
isArrayOfStrings(v) ? v : typeof v === 'string' && v.length ? [v] : [],
jsonString: (v: unknown) => (v ? JSON.stringify(v, null, 2) : '{}'),
/**
* Useful when deserializing strings that will be rendered inside of text areas or text inputs. We want
* a string like: "my\ttab" to render the same, not to render as "my<tab>tab".
*/
escapeBackslashes: (v: unknown) => {
if (typeof v === 'string') {
const s = JSON.stringify(v);
return s.slice(1, s.length - 1);
}
return v;
},
};
/**
@ -69,6 +81,41 @@ export const from = {
optionalArrayOfStrings: (v: string[]) => (v.length ? v : undefined),
undefinedIfValue: (value: unknown) => (v: boolean) => (v === value ? undefined : v),
emptyStringToUndefined: (v: unknown) => (v === '' ? undefined : v),
/**
* Useful when serializing user input from a <textarea /> that we want to later JSON.stringify but keep the same as what
* the user input. For instance, given "my\ttab", encoded as "my%5Ctab" will JSON.stringify to "my\\ttab", instead we want
* to keep the input exactly as the user entered it.
*/
unescapeBackslashes: (v: unknown) => {
if (typeof v === 'string') {
try {
return JSON.parse(`"${v}"`);
} catch (e) {
// Best effort
return v;
}
}
},
};
const isJSONString = (v: string) => {
try {
JSON.parse(`"${v}"`);
return true;
} catch (e) {
return false;
}
};
export const isJSONStringValidator: ValidationFunc = ({ value }) => {
if (typeof value !== 'string' || !isJSONString(value)) {
return {
message: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.jsonStringField.invalidStringMessage',
{ defaultMessage: 'Invalid JSON string.' }
),
};
}
};
export const EDITOR_PX_HEIGHT = {