From 49280f3e799b6deb596ba9a041b0ebe2bba83c77 Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Fri, 31 Jul 2026 21:55:03 +0300 Subject: [PATCH 1/2] Fix !!timestamp resolution for years 0000-0099, #775 --- src/tag/scalar/timestamp.ts | 22 ++++++++++++++++++++-- test/core/tags/timestamp.test.mjs | 5 +++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/tag/scalar/timestamp.ts b/src/tag/scalar/timestamp.ts index ce741238..6758be42 100644 --- a/src/tag/scalar/timestamp.ts +++ b/src/tag/scalar/timestamp.ts @@ -15,6 +15,24 @@ const YAML_TIMESTAMP_REGEXP = new RegExp( '(?:[ \\t]*(Z|([-+])([0-9][0-9]?)' + '(?::([0-9][0-9]))?))?$') +function makeUtcDate ( + year: number, + month: number, + day: number, + hour = 0, + minute = 0, + second = 0, + fraction = 0 +) { + const date = new Date(Date.UTC(year, month, day, hour, minute, second, fraction)) + + // Date.UTC() treats years 0..99 as 1900..1999. Restore the parsed YAML year + // before validating calendar normalization, e.g. reject 0001-02-29. + date.setUTCFullYear(year, month, day) + + return date +} + function resolveYamlTimestamp (source: string) { let match = YAML_DATE_REGEXP.exec(source) if (match === null) match = YAML_TIMESTAMP_REGEXP.exec(source) @@ -26,7 +44,7 @@ function resolveYamlTimestamp (source: string) { // Date-only form (`YYYY-MM-DD`) has no time captures. if (!match[4]) { - const date = new Date(Date.UTC(year, month, day)) + const date = makeUtcDate(year, month, day) // Reject dates that JS would normalize, e.g. 2023-02-29 -> 2023-03-01. if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== day) { return NOT_RESOLVED @@ -48,7 +66,7 @@ function resolveYamlTimestamp (source: string) { fraction = +value } - const date = new Date(Date.UTC(year, month, day, hour, minute, second, fraction)) + const date = makeUtcDate(year, month, day, hour, minute, second, fraction) // Reject invalid calendar dates before applying timezone offset. if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== day) { diff --git a/test/core/tags/timestamp.test.mjs b/test/core/tags/timestamp.test.mjs index 4b35c045..3eb2fe78 100644 --- a/test/core/tags/timestamp.test.mjs +++ b/test/core/tags/timestamp.test.mjs @@ -10,6 +10,8 @@ describe('tags', () => { - 2001-12-14 21:59:43.10 -5 # space separated - 2001-12-15 2:59:43.10 # no time zone (Z) - 2002-12-14 # date (00:00:00Z) +- 0001-01-01 +- 0050-06-15T12:30:00Z - 2002-1-1 # not a date # Other @@ -26,6 +28,8 @@ describe('tags', () => { new Date(Date.UTC(2001, 11, 15, 2, 59, 43, 100)), new Date(Date.UTC(2001, 11, 15, 2, 59, 43, 100)), new Date(Date.UTC(2002, 11, 14)), + new Date('0001-01-01T00:00:00.000Z'), + new Date('0050-06-15T12:30:00.000Z'), '2002-1-1', new Date(Date.UTC(2001, 11, 15, 3, 29, 43, 100)), @@ -48,6 +52,7 @@ describe('tags', () => { const invalid = [ '2023-99-99', '2023-02-30', + '0001-02-29', '2023-02-31 00:00:00', '2023-01-01 24:00:00', '2023-01-01 00:60:00', From 40fcb4f45c1f25e4e9495cf27094405d1d740881 Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Fri, 31 Jul 2026 22:39:52 +0300 Subject: [PATCH 2/2] Fix missing mapping values before document markers and reject unpaired mapping events, #784 --- src/ast/from_events.ts | 3 +++ src/parser/constructor.ts | 5 +++++ src/parser/parser.ts | 5 ----- test/core/ast/from_events.test.mjs | 20 ++++++++++++++++++++ test/core/parser/constructor.test.mjs | 20 ++++++++++++++++++++ test/core/parser/parser.test.mjs | 25 +++++++++++++++++++++++++ 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 test/core/ast/from_events.test.mjs create mode 100644 test/core/parser/constructor.test.mjs create mode 100644 test/core/parser/parser.test.mjs diff --git a/src/ast/from_events.ts b/src/ast/from_events.ts index 2e35f47a..f06d419f 100644 --- a/src/ast/from_events.ts +++ b/src/ast/from_events.ts @@ -214,6 +214,9 @@ function eventsToAst (events: Event[], options: FromEventsOptions): Document[] { case EVENT_POP: { const frame = state.frames.pop()! + if (frame.kind === 'mapping' && frame.key) { + throw new Error('incomplete mapping pair in event stream') + } if (frame.kind === 'document') { state.documents.push(frame.doc) } else { diff --git a/src/parser/constructor.ts b/src/parser/constructor.ts index 2a1f1080..89ab9c90 100644 --- a/src/parser/constructor.ts +++ b/src/parser/constructor.ts @@ -459,6 +459,11 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk case EVENT_POP: { const frame = state.frames.pop()! + if (frame.kind === 'mapping' && frame.hasKey) { + state.position = frame.keyPosition + throwError(state, 'incomplete mapping pair in event stream') + } + if (frame.kind === 'document') { state.documents.push(frame.value) } else { diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 432c22c7..a472395c 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1175,11 +1175,6 @@ function parseNode ( } } - if (state.position === state.lineStart && testDocumentSeparator(state)) { - state.depth-- - return false - } - if (indentStatus === 1) { while (true) { const ch = state.input.charCodeAt(state.position) diff --git a/test/core/ast/from_events.test.mjs b/test/core/ast/from_events.test.mjs new file mode 100644 index 00000000..1bdbca34 --- /dev/null +++ b/test/core/ast/from_events.test.mjs @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + CORE_SCHEMA, + eventsToAst, + EVENT_SCALAR, + parseEvents +} from 'js-yaml' + +describe('ast from_events', () => { + it('rejects a mapping event stream with an unpaired key', () => { + const source = 'key: value' + const events = parseEvents(source, {}) + const valueIndex = events.findLastIndex(event => event.type === EVENT_SCALAR) + + events.splice(valueIndex, 1) + + assert.throws(() => eventsToAst(events, { source, schema: CORE_SCHEMA })) + }) +}) diff --git a/test/core/parser/constructor.test.mjs b/test/core/parser/constructor.test.mjs new file mode 100644 index 00000000..b599dd4a --- /dev/null +++ b/test/core/parser/constructor.test.mjs @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + constructFromEvents, + EVENT_SCALAR, + parseEvents, + YAMLException +} from 'js-yaml' + +describe('constructor', () => { + it('rejects a mapping event stream with an unpaired key', () => { + const source = 'key: value' + const events = parseEvents(source, {}) + const valueIndex = events.findLastIndex(event => event.type === EVENT_SCALAR) + + events.splice(valueIndex, 1) + + assert.throws(() => constructFromEvents(events, { source }), YAMLException) + }) +}) diff --git a/test/core/parser/parser.test.mjs b/test/core/parser/parser.test.mjs new file mode 100644 index 00000000..2d20f0b0 --- /dev/null +++ b/test/core/parser/parser.test.mjs @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { EVENT_SCALAR, getScalarValue, loadAll, parseEvents } from 'js-yaml' + +describe('parser', () => { + it('keeps an implicit null mapping value before a document marker', () => { + const samples = [ + ['a:\n---\nx: 1\n', ['a', '', 'x', '1']], + ['a:\n...\n', ['a', '']] + ] + + for (const [source, expected] of samples) { + const values = parseEvents(source, {}) + .filter(event => event.type === EVENT_SCALAR) + .map(event => getScalarValue(source, event)) + + assert.deepEqual(values, expected) + } + + assert.deepEqual( + loadAll('a:\n---\nx: 1\n'), + [{ a: null }, { x: 1 }] + ) + }) +})