Skip to content

Commit

Permalink
[Security Solution] Adds diff algorithm and unit tests for multi-line…
Browse files Browse the repository at this point in the history
… string fields (#188022)

## Summary

Related ticket: #180159

Adds diff algorithm for multi-line string fields and unit tests to cover
all use cases. Also adds the
[`node-diff3`](https://www.npmjs.com/package/node-diff3#3-way-diff-and-merging)
package to utilize in the diffing logic to both determine if conflicts
exist and merge the 3 rule versions together to create the returned
merged version.


### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios



### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
  • Loading branch information
dplumlee authored Jul 17, 2024
1 parent 3901734 commit 5f843a8
Show file tree
Hide file tree
Showing 5 changed files with 323 additions and 3 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,7 @@
"monaco-editor": "^0.44.0",
"monaco-yaml": "^5.1.0",
"mustache": "^2.3.2",
"node-diff3": "^3.1.2",
"node-fetch": "^2.6.7",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.14",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { numberDiffAlgorithm } from './number_diff_algorithm';
export { singleLineStringDiffAlgorithm } from './single_line_string_diff_algorithm';
export { scalarArrayDiffAlgorithm } from './scalar_array_diff_algorithm';
export { simpleDiffAlgorithm } from './simple_diff_algorithm';
export { multiLineStringDiffAlgorithm } from './multi_line_string_diff_algorithm';
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* 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 type { ThreeVersionsOf } from '../../../../../../../../common/api/detection_engine';
import {
ThreeWayDiffOutcome,
ThreeWayMergeOutcome,
MissingVersion,
} from '../../../../../../../../common/api/detection_engine';
import { multiLineStringDiffAlgorithm } from './multi_line_string_diff_algorithm';

describe('multiLineStringDiffAlgorithm', () => {
it('returns current_version as merged output if there is no update - scenario AAA', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: 'My description.\nThis is a second line.',
current_version: 'My description.\nThis is a second line.',
target_version: 'My description.\nThis is a second line.',
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
has_conflict: false,
})
);
});

it('returns current_version as merged output if current_version is different and there is no update - scenario ABA', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: 'My description.\nThis is a second line.',
current_version: 'My GREAT description.\nThis is a second line.',
target_version: 'My description.\nThis is a second line.',
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.CustomizedValueNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
has_conflict: false,
})
);
});

it('returns target_version as merged output if current_version is the same and there is an update - scenario AAB', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: 'My description.\nThis is a second line.',
current_version: 'My description.\nThis is a second line.',
target_version: 'My GREAT description.\nThis is a second line.',
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
has_conflict: false,
})
);
});

it('returns current_version as merged output if current version is different but it matches the update - scenario ABB', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: 'My description.\nThis is a second line.',
current_version: 'My GREAT description.\nThis is a second line.',
target_version: 'My GREAT description.\nThis is a second line.',
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.CustomizedValueSameUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
has_conflict: false,
})
);
});

describe('if all three versions are different - scenario ABC', () => {
it('returns a computated merged version without a conflict if 3 way merge is possible', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: `My description.\f\nThis is a second\u2001 line.\f\nThis is a third line.`,
current_version: `My GREAT description.\f\nThis is a second\u2001 line.\f\nThis is a third line.`,
target_version: `My description.\f\nThis is a second\u2001 line.\f\nThis is a GREAT line.`,
};

const expectedMergedVersion = `My GREAT description.\f\nThis is a second\u2001 line.\f\nThis is a GREAT line.`;

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: expectedMergedVersion,
diff_outcome: ThreeWayDiffOutcome.CustomizedValueCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Merged,
has_conflict: false,
})
);
});

it('returns the current_version with a conflict if 3 way merge is not possible', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: 'My description.\nThis is a second line.',
current_version: 'My GREAT description.\nThis is a third line.',
target_version: 'My EXCELLENT description.\nThis is a fourth.',
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.CustomizedValueCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Conflict,
has_conflict: true,
})
);
});
});

describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'My description.\nThis is a second line.',
target_version: 'My description.\nThis is a second line.',
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
has_conflict: false,
})
);
});

it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: `My GREAT description.\nThis is a second line.`,
target_version: `My description.\nThis is a second line, now longer.`,
};

const result = multiLineStringDiffAlgorithm(mockVersions);

expect(result).toEqual(
expect.objectContaining({
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
has_conflict: false,
})
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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 { merge } from 'node-diff3';
import { assertUnreachable } from '../../../../../../../../common/utility_types';
import type {
ThreeVersionsOf,
ThreeWayDiff,
} from '../../../../../../../../common/api/detection_engine/prebuilt_rules';
import {
determineDiffOutcome,
determineIfValueCanUpdate,
ThreeWayDiffOutcome,
ThreeWayMergeOutcome,
MissingVersion,
} from '../../../../../../../../common/api/detection_engine/prebuilt_rules';

/**
* Diff algorithm used for string fields that contain multiple lines
*/
export const multiLineStringDiffAlgorithm = (
versions: ThreeVersionsOf<string>
): ThreeWayDiff<string> => {
const {
base_version: baseVersion,
current_version: currentVersion,
target_version: targetVersion,
} = versions;

const diffOutcome = determineDiffOutcome(baseVersion, currentVersion, targetVersion);
const valueCanUpdate = determineIfValueCanUpdate(diffOutcome);

const { mergeOutcome, mergedVersion } = mergeVersions({
baseVersion,
currentVersion,
targetVersion,
diffOutcome,
});

return {
base_version: baseVersion,
current_version: currentVersion,
target_version: targetVersion,
merged_version: mergedVersion,

diff_outcome: diffOutcome,
merge_outcome: mergeOutcome,
has_update: valueCanUpdate,
has_conflict: mergeOutcome === ThreeWayMergeOutcome.Conflict,
};
};

interface MergeResult {
mergeOutcome: ThreeWayMergeOutcome;
mergedVersion: string;
}

interface MergeArgs {
baseVersion: string | MissingVersion;
currentVersion: string;
targetVersion: string;
diffOutcome: ThreeWayDiffOutcome;
}

const mergeVersions = ({
baseVersion,
currentVersion,
targetVersion,
diffOutcome,
}: MergeArgs): MergeResult => {
switch (diffOutcome) {
case ThreeWayDiffOutcome.StockValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueSameUpdate: {
return {
mergeOutcome: ThreeWayMergeOutcome.Current,
mergedVersion: currentVersion,
};
}
case ThreeWayDiffOutcome.StockValueCanUpdate: {
return {
mergeOutcome: ThreeWayMergeOutcome.Target,
mergedVersion: targetVersion,
};
}
case ThreeWayDiffOutcome.CustomizedValueCanUpdate: {
if (baseVersion === MissingVersion) {
return {
mergeOutcome: ThreeWayMergeOutcome.Conflict,
mergedVersion: currentVersion,
};
}
const mergedVersion = merge(currentVersion, baseVersion, targetVersion, {
stringSeparator: /(\S+|\s+)/g, // Retains all whitespace, which we keep to preserve formatting
});

return mergedVersion.conflict
? {
mergeOutcome: ThreeWayMergeOutcome.Conflict,
mergedVersion: currentVersion,
}
: {
mergeOutcome: ThreeWayMergeOutcome.Merged,
mergedVersion: mergedVersion.result.join(''),
};
}
default:
return assertUnreachable(diffOutcome);
}
};
36 changes: 33 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -23772,6 +23772,11 @@ node-cache@^5.1.0:
dependencies:
clone "2.x"

node-diff3@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/node-diff3/-/node-diff3-3.1.2.tgz#49df8d821dc9cbab87bfd6182171d90169613a97"
integrity sha512-wUd9TWy059I8mZdH6G3LPNlAEfxDvXtn/RcyFrbqL3v34WlDxn+Mh4HDhOwWuaMk/ROVepe5tTpnGHbve6Db2g==

node-dir@^0.1.10:
version "0.1.17"
resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
Expand Down Expand Up @@ -29229,7 +29234,7 @@ string-replace-loader@^2.2.0:
loader-utils "^1.2.3"
schema-utils "^1.0.0"

"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand All @@ -29247,6 +29252,15 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"

"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
Expand Down Expand Up @@ -29357,7 +29371,7 @@ stringify-object@^3.2.1:
is-obj "^1.0.1"
is-regexp "^1.0.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand All @@ -29371,6 +29385,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
dependencies:
ansi-regex "^2.0.0"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
Expand Down Expand Up @@ -32244,7 +32265,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -32270,6 +32291,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down

0 comments on commit 5f843a8

Please sign in to comment.