Skip to content

Commit 5f76549

Browse files
authored
fix: properly handle XML normalizedString/token (#1116)
fixes #1098 --------- Signed-off-by: Jan Kowalleck <[email protected]>
1 parent b209685 commit 5f76549

File tree

4 files changed

+189
-33
lines changed

4 files changed

+189
-33
lines changed

HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ All notable changes to this project will be documented in this file.
66

77
<!-- add unreleased items here -->
88

9+
* Fixed
10+
* XML: properly handle `normalizedString` & `token` ([#1098] via [#1116])
911
* Build
1012
* Use _TypeScript_ `v5.5.3` now, was `v5.4.5` (via [#1108])
1113
* Use _webpack_ `v5.92.1` now, was `v5.91.0` (via [#1091], [#1094])
1214

1315
[#1091]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1091
1416
[#1094]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1094
17+
[#1098]: https://github.com/CycloneDX/cyclonedx-javascript-library/issues/1098
1518
[#1108]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1108
19+
[#1116]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1116
1620

1721
## 6.10.0 -- 2024-06-06
1822

src/serialize/xml/_xsd.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*!
2+
This file is part of CycloneDX JavaScript Library.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
SPDX-License-Identifier: Apache-2.0
17+
Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
20+
// region normalizedString
21+
22+
/** search-item for {@link normalizedString} */
23+
const _normalizeStringForbiddenSearch = /\r\n|\t|\n|\r/g
24+
/** replace-item for {@link normalizedString} */
25+
const _normalizeStringForbiddenReplace = ' '
26+
27+
/**
28+
* Make a 'normalizedString', adhering XML spec.
29+
*
30+
* @see {@link http://www.w3.org/TR/xmlschema-2/#normalizedString}
31+
*
32+
* @remarks
33+
*
34+
* quote from the XML schema spec:
35+
*
36+
* *normalizedString* represents white space normalized strings.
37+
* The [·value space·](https://www.w3.org/TR/xmlschema-2/#dt-value-space) of normalizedString is the set of strings that do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters.
38+
* The [·lexical space·](https://www.w3.org/TR/xmlschema-2/#dt-lexical-space) of normalizedString is the set of strings that do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters.
39+
* The [·base type·](https://www.w3.org/TR/xmlschema-2/#dt-basetype) of normalizedString is [string](https://www.w3.org/TR/xmlschema-2/#string).
40+
*
41+
* @internal
42+
*/
43+
export function normalizedString(s: string): string {
44+
return s.replace(_normalizeStringForbiddenSearch, _normalizeStringForbiddenReplace)
45+
}
46+
47+
// endregion
48+
49+
// region token
50+
51+
/** search-item for {@link token} */
52+
const _tokenMultispaceSearch = / {2,}/g
53+
/** replace-item for {@link token} */
54+
const _tokenMultispaceReplace = ' '
55+
56+
/**
57+
* Make a 'token', adhering XML spec.
58+
*
59+
* @see {@link http://www.w3.org/TR/xmlschema-2/#token}
60+
*
61+
* @remarks
62+
*
63+
* quote from the XML schema spec:
64+
*
65+
* *token* represents tokenized strings.
66+
* The [·value space·](https://www.w3.org/TR/xmlschema-2/#dt-value-space) of token is the set of strings that do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters, that have no leading or trailing spaces (#x20) and that have no internal sequences of two or more spaces.
67+
* The [·lexical space·](https://www.w3.org/TR/xmlschema-2/#dt-lexical-space) of token is the set of strings that do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters, that have no leading or trailing spaces (#x20) and that have no internal sequences of two or more spaces.
68+
* The [·base type·](https://www.w3.org/TR/xmlschema-2/#dt-basetype) of token is [normalizedString](https://www.w3.org/TR/xmlschema-2/#normalizedString).
69+
*
70+
* @internal
71+
*/
72+
export function token(s: string): string {
73+
// according to spec, `token` inherits from `normalizedString` - so we utilize it here.
74+
return normalizedString(s).trim().replace(_tokenMultispaceSearch, _tokenMultispaceReplace)
75+
}
76+
77+
// endregion

src/serialize/xml/normalize.ts

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { isSupportedSpdxId } from '../../spdx'
3030
import type { _SpecProtocol as Spec } from '../../spec/_protocol'
3131
import { Version as SpecVersion } from '../../spec/enums'
3232
import type { NormalizerOptions } from '../types'
33+
import { normalizedString, token} from './_xsd'
3334
import type { SimpleXml } from './types'
3435
import { XmlSchema } from './types'
3536

@@ -295,7 +296,7 @@ export class LifecycleNormalizer extends BaseXmlNormalizer<Models.Lifecycle> {
295296
type: 'element',
296297
name: elementName,
297298
children: [
298-
makeTextElement(data.name, 'name'),
299+
makeTextElement(data.name, 'name', normalizedString),
299300
makeOptionalTextElement(data.description, 'description')
300301
].filter(isNotUndefined)
301302
}
@@ -338,9 +339,9 @@ export class ToolNormalizer extends BaseXmlNormalizer<Models.Tool> {
338339
type: 'element',
339340
name: elementName,
340341
children: [
341-
makeOptionalTextElement(data.vendor, 'vendor'),
342-
makeOptionalTextElement(data.name, 'name'),
343-
makeOptionalTextElement(data.version, 'version'),
342+
makeOptionalTextElement(data.vendor, 'vendor', normalizedString),
343+
makeOptionalTextElement(data.name, 'name', normalizedString),
344+
makeOptionalTextElement(data.version, 'version', normalizedString),
344345
hashes,
345346
externalReferences
346347
].filter(isNotUndefined)
@@ -364,7 +365,7 @@ export class HashNormalizer extends BaseXmlNormalizer<Models.Hash> {
364365
type: 'element',
365366
name: elementName,
366367
attributes: { alg: algorithm },
367-
children: content
368+
children: token(content)
368369
}
369370
: undefined
370371
}
@@ -386,9 +387,9 @@ export class OrganizationalContactNormalizer extends BaseXmlNormalizer<Models.Or
386387
type: 'element',
387388
name: elementName,
388389
children: [
389-
makeOptionalTextElement(data.name, 'name'),
390-
makeOptionalTextElement(data.email, 'email'),
391-
makeOptionalTextElement(data.phone, 'phone')
390+
makeOptionalTextElement(data.name, 'name', normalizedString),
391+
makeOptionalTextElement(data.email, 'email', normalizedString),
392+
makeOptionalTextElement(data.phone, 'phone', normalizedString)
392393
].filter(isNotUndefined)
393394
}
394395
}
@@ -408,7 +409,7 @@ export class OrganizationalEntityNormalizer extends BaseXmlNormalizer<Models.Org
408409
type: 'element',
409410
name: elementName,
410411
children: [
411-
makeOptionalTextElement(data.name, 'name'),
412+
makeOptionalTextElement(data.name, 'name', normalizedString),
412413
...makeTextElementIter(Array.from(
413414
data.url, (s): string => escapeUri(s.toString())
414415
), options, 'url'
@@ -442,7 +443,8 @@ export class ComponentNormalizer extends BaseXmlNormalizer<Models.Component> {
442443
: makeOptionalTextElement
443444
)(
444445
data.version ?? '',
445-
'version'
446+
'version',
447+
normalizedString
446448
)
447449
const hashes: SimpleXml.Element | undefined = data.hashes.size > 0
448450
? {
@@ -494,16 +496,16 @@ export class ComponentNormalizer extends BaseXmlNormalizer<Models.Component> {
494496
},
495497
children: [
496498
supplier,
497-
makeOptionalTextElement(data.author, 'author'),
498-
makeOptionalTextElement(data.publisher, 'publisher'),
499-
makeOptionalTextElement(data.group, 'group'),
500-
makeTextElement(data.name, 'name'),
499+
makeOptionalTextElement(data.author, 'author', normalizedString),
500+
makeOptionalTextElement(data.publisher, 'publisher', normalizedString),
501+
makeOptionalTextElement(data.group, 'group', normalizedString),
502+
makeTextElement(data.name, 'name', normalizedString),
501503
version,
502-
makeOptionalTextElement(data.description, 'description'),
504+
makeOptionalTextElement(data.description, 'description', normalizedString),
503505
makeOptionalTextElement(data.scope, 'scope'),
504506
hashes,
505507
licenses,
506-
makeOptionalTextElement(data.copyright, 'copyright'),
508+
makeOptionalTextElement(data.copyright, 'copyright', normalizedString),
507509
makeOptionalTextElement(data.cpe, 'cpe'),
508510
makeOptionalTextElement(data.purl, 'purl'),
509511
swid,
@@ -587,7 +589,7 @@ export class LicenseNormalizer extends BaseXmlNormalizer<Models.License> {
587589
: undefined
588590
},
589591
children: [
590-
makeTextElement(data.name, 'name'),
592+
makeTextElement(data.name, 'name', normalizedString),
591593
data.text === undefined
592594
? undefined
593595
: this._factory.makeForAttachment().normalize(data.text, options, 'text'),
@@ -621,7 +623,7 @@ export class LicenseNormalizer extends BaseXmlNormalizer<Models.License> {
621623
}
622624

623625
#normalizeLicenseExpression (data: Models.LicenseExpression): SimpleXml.Element {
624-
const elem = makeTextElement(data.expression, 'expression')
626+
const elem = makeTextElement(data.expression, 'expression', normalizedString)
625627
elem.attributes = {
626628
acknowledgement: this._factory.spec.supportsLicenseAcknowledgement
627629
? data.acknowledgement
@@ -722,7 +724,9 @@ export class AttachmentNormalizer extends BaseXmlNormalizer<Models.Attachment> {
722724
type: 'element',
723725
name: elementName,
724726
attributes: {
725-
'content-type': data.contentType || undefined,
727+
'content-type': data.contentType
728+
? normalizedString(data.contentType)
729+
: undefined,
726730
encoding: data.encoding || undefined
727731
},
728732
children: data.content.toString()
@@ -738,7 +742,7 @@ export class PropertyNormalizer extends BaseXmlNormalizer<Models.Property> {
738742
attributes: {
739743
name: data.name
740744
},
741-
children: data.value
745+
children: normalizedString(data.value)
742746
}
743747
}
744748

@@ -875,7 +879,7 @@ export class VulnerabilityNormalizer extends BaseXmlNormalizer<Models.Vulnerabil
875879
name: elementName,
876880
attributes: { 'bom-ref': data.bomRef.value || undefined },
877881
children: [
878-
makeOptionalTextElement(data.id, 'id'),
882+
makeOptionalTextElement(data.id, 'id', normalizedString),
879883
data.source === undefined
880884
? undefined
881885
: this._factory.makeForVulnerabilitySource().normalize(data.source, options, 'source'),
@@ -918,7 +922,7 @@ export class VulnerabilitySourceNormalizer extends BaseXmlNormalizer<Models.Vuln
918922
type: 'element',
919923
name: elementName,
920924
children: [
921-
makeOptionalTextElement(data.name, 'name'),
925+
makeOptionalTextElement(data.name, 'name', normalizedString),
922926
XmlSchema.isAnyURI(url)
923927
? makeTextElement(url, 'url')
924928
: undefined
@@ -962,7 +966,7 @@ export class VulnerabilityRatingNormalizer extends BaseXmlNormalizer<Models.Vuln
962966
this._factory.spec.supportsVulnerabilityRatingMethod(data.method)
963967
? makeOptionalTextElement(data.method, 'method')
964968
: undefined,
965-
makeOptionalTextElement(data.vector, 'vector'),
969+
makeOptionalTextElement(data.vector, 'vector', normalizedString),
966970
makeOptionalTextElement(data.justification, 'justification')
967971
].filter(isNotUndefined)
968972
}
@@ -1106,7 +1110,7 @@ export class VulnerabilityAffectedVersionNormalizer extends BaseXmlNormalizer<Mo
11061110
type: 'element',
11071111
name: elementName,
11081112
children: [
1109-
makeTextElement(data.version, 'version'),
1113+
makeTextElement(data.version, 'version', normalizedString),
11101114
makeOptionalTextElement(data.status, 'status')
11111115
].filter(isNotUndefined)
11121116
}
@@ -1117,7 +1121,7 @@ export class VulnerabilityAffectedVersionNormalizer extends BaseXmlNormalizer<Mo
11171121
type: 'element',
11181122
name: elementName,
11191123
children: [
1120-
makeTextElement(data.range, 'range'),
1124+
makeTextElement(data.range, 'range', normalizedString),
11211125
makeOptionalTextElement(data.status, 'status')
11221126
].filter(isNotUndefined)
11231127
}
@@ -1136,32 +1140,35 @@ export class VulnerabilityAffectedVersionNormalizer extends BaseXmlNormalizer<Mo
11361140

11371141
type StrictTextElement = SimpleXml.TextElement & { children: string }
11381142

1139-
function makeOptionalTextElement (data: null | undefined | Stringable, elementName: string): undefined | StrictTextElement {
1140-
const s = data?.toString() ?? ''
1143+
type TextElementModifier = (i:string) => string
1144+
const noTEM: TextElementModifier = (s) => s
1145+
1146+
function makeOptionalTextElement (data: null | undefined | Stringable, elementName: string, mod: TextElementModifier = noTEM): undefined | StrictTextElement {
1147+
const s = mod(data?.toString() ?? '')
11411148
return s.length > 0
11421149
? makeTextElement(s, elementName)
11431150
: undefined
11441151
}
11451152

1146-
function makeTextElement (data: Stringable, elementName: string): StrictTextElement {
1153+
function makeTextElement (data: Stringable, elementName: string, mod: TextElementModifier = noTEM): StrictTextElement {
11471154
return {
11481155
type: 'element',
11491156
name: elementName,
1150-
children: data.toString()
1157+
children: mod(data.toString())
11511158
}
11521159
}
11531160

1154-
function makeTextElementIter (data: Iterable<Stringable>, options: NormalizerOptions, elementName: string): StrictTextElement[] {
1155-
const r: StrictTextElement[] = Array.from(data, d => makeTextElement(d, elementName))
1161+
function makeTextElementIter (data: Iterable<Stringable>, options: NormalizerOptions, elementName: string, mod: TextElementModifier = noTEM): StrictTextElement[] {
1162+
const r: StrictTextElement[] = Array.from(data, d => makeTextElement(d, elementName, mod))
11561163
if (options.sortLists ?? false) {
11571164
r.sort(({ children: a }, { children: b }) => a.localeCompare(b))
11581165
}
11591166
return r
11601167
}
11611168

1162-
function makeOptionalDateTimeElement (data: null | undefined | Date, elementName: string): undefined | StrictTextElement {
1169+
function makeOptionalDateTimeElement (data: null | undefined | Date, elementName: string, mod: TextElementModifier = noTEM): undefined | StrictTextElement {
11631170
const d = data?.toISOString()
11641171
return d === undefined
11651172
? undefined
1166-
: makeTextElement(d, elementName)
1173+
: makeTextElement(d, elementName, mod)
11671174
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*!
2+
This file is part of CycloneDX JavaScript Library.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
SPDX-License-Identifier: Apache-2.0
17+
Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
20+
const assert = require('assert')
21+
const { suite, test } = require('mocha')
22+
23+
const {
24+
normalizedString,
25+
token
26+
} = require('../../dist.node/serialize/xml/_xsd.js')
27+
28+
suite('Serialize.XML._xsd', () => {
29+
const normalizedStringCases = {
30+
'': '',
31+
'123': '123',
32+
' 0 1\r\n2\t3\n4\t': ' 0 1 2 3 4 ',
33+
' 0 1\r\n 2 \t3 \n 4 \t': ' 0 1 2 3 4 ',
34+
}
35+
36+
const tokenCases = {
37+
'': '',
38+
'123': '123',
39+
' 0 1 \r\n2\t 3 \n4\n ': '0 1 2 3 4',
40+
' 0 1\r\n 2 \t3 \n 4 \t ': '0 1 2 3 4',
41+
}
42+
43+
/**
44+
* @param {string} s
45+
* @return {string}
46+
*/
47+
function escapeTNR(s) {
48+
return s
49+
.replace(/\t/g, '\\t')
50+
.replace(/\n/g, '\\n')
51+
.replace(/\r/g, '\\r')
52+
}
53+
54+
suite('normalizedString()', () => {
55+
for (const [input, expected] of Object.entries(normalizedStringCases)) {
56+
test(`i: "${escapeTNR(input)}"`, () => {
57+
assert.strictEqual(normalizedString(input), expected)
58+
})
59+
}
60+
})
61+
suite('token()', () => {
62+
for (const [input, expected] of Object.entries(tokenCases)) {
63+
test(`i: "${escapeTNR(input)}"`, () => {
64+
assert.strictEqual(token(input), expected)
65+
})
66+
}
67+
})
68+
})

0 commit comments

Comments
 (0)