From f15c5deeb49db2de136ecb73ff6df32f86e26d74 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 20 Mar 2026 08:21:11 +0100 Subject: [PATCH] Improve date parsing to handle YYYYMMDD format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add handling for ambiguous date formats to avoid misinterpretation. In Horde_Date::__construct(), adjust the deprecated “numeric string timestamp” cast so an 8-digit YYYYMMDD string (with valid month/day) is not cast to int (timestamp). fix: Solve ambiguity for yyyymmdd format --- lib/Horde/Date.php | 40 ++++-- test/Unnamespaced/DateTest.php | 226 +++++++++++++++++++++++++++++---- 2 files changed, 232 insertions(+), 34 deletions(-) diff --git a/lib/Horde/Date.php b/lib/Horde/Date.php index 66316c3..c9dde12 100644 --- a/lib/Horde/Date.php +++ b/lib/Horde/Date.php @@ -540,16 +540,36 @@ public function __construct($date = null, $timezone = null, $locale = null) if (is_string($date)) { $date = trim($date, '"'); - // DEPRECATED: Handle Unix timestamp strings for backwards compatibility. - // This behavior is deprecated and will be removed in the next major version. - // Callers should pass timestamps as integers, not strings. - // Only match strings that look like Unix timestamps (8-11 digits). - // Positive: 8-10 digits (e.g., "946684800" = 2000-01-01, "1773944669" = 2026) - // Negative: 9-11 digits with minus (e.g., "-631152000" = 1950-01-01) - // Excludes short numbers (< 8 digits) and ISO date strings (12+ digits). - // Related: https://github.com/horde/Date/issues/6 - // Related: https://github.com/horde/ActiveSync/pull/15 - if (preg_match('/^(-\d{9,11}|\d{8,10})$/', $date)) { + // Check for YYYYMMDD format first (exactly 8 digits with valid date components). + // This must be checked before the deprecated timestamp string BC logic below, + // because 8-digit strings are ambiguous (could be YYYYMMDD or a Unix timestamp). + // YYYYMMDD requirements: + // - Exactly 8 digits + // - Year >= 1000 (looks like a legitimate year) + // - Month 1-12 + // - Day 1-31 + // This prevents dates like "19700101" from being interpreted as a Unix timestamp. + if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $date, $m) + && $m[1] >= 1000 + && $m[2] >= 1 && $m[2] <= 12 + && $m[3] >= 1 && $m[3] <= 31) { + // Valid YYYYMMDD format - let it fall through to the date parsing logic below + } elseif (preg_match('/^-?\d{9,11}$/', $date)) { + // DEPRECATED: Handle Unix timestamp strings for backwards compatibility. + // This behavior is deprecated and will be removed in the next major version. + // Callers should pass timestamps as integers, not strings. + // Matches strings with 9-11 digits (positive or negative). + // This effectively blocks years 1970-2001 from timestamp interpretation, + // forcing them through proper date parsing instead. + // Excludes longer strings (12+ digits) which are likely ISO datetime strings. + // Examples: + // - "946684800" (9 digits) = 2000-01-01 timestamp + // - "1773944669" (10 digits) = 2026 timestamp + // - "-631152000" (10 digits) = 1950-01-01 timestamp + // - "19700101" (8 digits) = NOT matched, handled as YYYYMMDD above + // - "20010203040506" (14 digits) = NOT matched, falls through to DateTime + // Related: https://github.com/horde/Date/issues/6 + // Related: https://github.com/horde/ActiveSync/pull/15 $date = (int)$date; } } diff --git a/test/Unnamespaced/DateTest.php b/test/Unnamespaced/DateTest.php index 96fc7df..4ba97dd 100644 --- a/test/Unnamespaced/DateTest.php +++ b/test/Unnamespaced/DateTest.php @@ -726,55 +726,233 @@ public function testIntegerTimestampNoDeprecation(): void $date = new Horde_Date(-631152000); $this->assertEquals(1950, $date->year); } - + /** - * Test numeric timestamp string handling (deprecated BC support) + * Test YYYYMMDD string format validation * - * DEPRECATED: SOME numeric strings are silently accepted for BC compatibility. - * This behavior will be removed in next major version. - * Callers should pass objects or integer timestamps instead. + * 8-digit strings should be interpreted as YYYYMMDD when: + * - Exactly 8 digits + * - Year >= 1000 (legitimate year) + * - Month 1-12 + * - Day 1-31 * - * The Horde_Date constructor is the wrong place to guess what a caller means. + * This prevents ambiguous strings like "19700101" from being + * interpreted as Unix timestamps. * - * @link https://github.com/horde/Date/issues/6 - * @link https://github.com/horde/ActiveSync/pull/15 + * @link https://github.com/horde/Date/pull/8 */ - public function testNumericTimestampStringDeprecated(): void + public function testYYYYMMDDStringFormat(): void { - // Pre-1970 timestamp as string - accepted for BC - $date = new Horde_Date('-631152000'); // 1950-01-01 - $this->assertEquals(1950, $date->year); + // Valid YYYYMMDD strings + $date = new Horde_Date('19700101'); // 1970-01-01 + $this->assertEquals(1970, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); + + $date = new Horde_Date('20011231'); // 2001-12-31 + $this->assertEquals(2001, $date->year); + $this->assertEquals(12, $date->month); + $this->assertEquals(31, $date->mday); + + $date = new Horde_Date('10000101'); // 1000-01-01 (minimum valid year) + $this->assertEquals(1000, $date->year); $this->assertEquals(1, $date->month); $this->assertEquals(1, $date->mday); + + $date = new Horde_Date('20260320'); // 2026-03-20 + $this->assertEquals(2026, $date->year); + $this->assertEquals(3, $date->month); + $this->assertEquals(20, $date->mday); } /** - * Test positive numeric timestamp string (deprecated BC support) + * Test that 9+ digit strings are still treated as timestamps (deprecated BC) * * @link https://github.com/horde/Date/issues/6 * @link https://github.com/horde/ActiveSync/pull/15 */ - public function testPositiveNumericTimestampStringDeprecated(): void + public function testLongNumericStringsAsTimestamps(): void { - $date = new Horde_Date('1773944669'); // 2026-03-19 (from ActiveSync PR) + // 10-digit positive timestamp + $date = new Horde_Date('1773944669'); // 2026-03-19 $this->assertEquals(2026, $date->year); $this->assertEquals(3, $date->month); $this->assertEquals(19, $date->mday); + + // 10-digit negative timestamp + $date = new Horde_Date('-631152000'); // 1950-01-01 + $this->assertEquals(1950, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); + + // 9-digit timestamp (blocks years 1970-2001 from timestamp interpretation) + $date = new Horde_Date('946684800'); // 2000-01-01 00:00:00 UTC + $this->assertEquals(2000, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); } /** - * Test that integer timestamps still work without deprecation + * Test ambiguous 8-digit strings - YYYYMMDD vs Unix timestamp * - * @link https://github.com/horde/Date/issues/6 + * 8-digit numeric strings are inherently ambiguous: + * - Could be YYYYMMDD date format (e.g., "19700101" = 1970-01-01) + * - Could be Unix timestamp (e.g., "19700101" = 228 days after epoch) + * + * Resolution strategy (implemented in this PR): + * 1. If exactly 8 digits AND year >= 1000 AND valid month (1-12) AND valid day (1-31) + * -> Treat as YYYYMMDD + * 2. Otherwise -> Fall through to other parsing logic (may fail or use DateTime) + * + * This prevents dates between 1970-2001 from being misinterpreted as timestamps. + * + * @link https://github.com/horde/Date/pull/8 */ - public function testIntegerTimestampNoDeprecation(): void + public function testAmbiguousEightDigitStrings(): void { - // Should NOT trigger deprecation - $date = new Horde_Date(1773944669); - $this->assertEquals(2026, $date->year); + // Valid YYYYMMDD with valid date components - interpreted as date + $date = new Horde_Date('19700101'); // 1970-01-01 (NOT timestamp 19700101) + $this->assertEquals(1970, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); + $this->assertEquals(0, $date->hour); // Time should be 00:00:00 - // Pre-1970 integer - $date = new Horde_Date(-631152000); - $this->assertEquals(1950, $date->year); + $date = new Horde_Date('20011231'); // 2001-12-31 + $this->assertEquals(2001, $date->year); + $this->assertEquals(12, $date->month); + $this->assertEquals(31, $date->mday); + + // Edge case: Minimum valid year (1000) + $date = new Horde_Date('10000101'); // 1000-01-01 + $this->assertEquals(1000, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); + } + + /** + * Test invalid YYYYMMDD patterns that should NOT be treated as dates + * + * These 8-digit strings don't pass YYYYMMDD validation (year < 1000) and will + * fall through to DateTime parsing, which may interpret them differently. + * + * @link https://github.com/horde/Date/pull/8 + */ + public function testInvalidYYYYMMDDPatterns(): void + { + // Year < 1000 - doesn't pass our YYYYMMDD validation (year >= 1000) + // "09991231" fails year >= 1000 check, falls through to DateTime + // DateTime correctly parses it as year 999 + $date = new Horde_Date('09991231'); + $this->assertEquals(999, $date->year); // DateTime handles it correctly + $this->assertEquals(12, $date->month); + $this->assertEquals(31, $date->mday); + + // Invalid month (month 13) + // "19701399" fails month <= 12 check, falls through to DateTime + // DateTime will reject or mangle this + try { + $date = new Horde_Date('19701399'); + // If it doesn't throw, it was interpreted as something else + $this->assertNotEquals(13, $date->month); // Month 13 is invalid + } catch (\Horde_Date_Exception $e) { + // Expected: DateTime rejects invalid month + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + } + + // Invalid day (day 32) + // "19700132" fails day <= 31 check, falls through to DateTime + try { + $date = new Horde_Date('19700132'); + $this->assertNotEquals(32, $date->mday); // Day 32 is invalid + } catch (\Horde_Date_Exception $e) { + // Expected: DateTime rejects invalid day + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + } + + // Invalid month and day (month 00) + // "19700001" fails month >= 1 check + try { + $date = new Horde_Date('19700001'); + $this->assertNotEquals(0, $date->month); // Month 0 is invalid + } catch (\Horde_Date_Exception $e) { + // Expected: DateTime rejects invalid month + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + } + } + + /** + * Test that 12 and 14-digit datetime strings work correctly + * + * These formats are handled by explicit regex patterns (line 599, 609): + * - 14 digits: YYYYMMDDHHmmss (e.g., "20010203040506") + * - 12 digits: YYYYMMDDHHmm - NOT explicitly handled, relies on DateTime + * + * @link https://github.com/horde/Date/pull/8 + */ + public function testLongNumericDateTimeStrings(): void + { + // 14-digit format: YYYYMMDDHHmmss (explicitly handled at line 599) + $date = new Horde_Date('20010203040506'); // 2001-02-03 04:05:06 + $this->assertEquals(2001, $date->year); + $this->assertEquals(2, $date->month); + $this->assertEquals(3, $date->mday); + $this->assertEquals(4, $date->hour); + $this->assertEquals(5, $date->min); + $this->assertEquals(6, $date->sec); + + // 12-digit format: YYYYMMDDHHmm (NOT explicitly handled) + // Falls through to DateTime - behavior depends on PHP version + // This documents that 12-digit format support is NOT guaranteed + try { + $date = new Horde_Date('197001010130'); // 1970-01-01 01:30 + // If DateTime accepts it, these should match + $this->assertEquals(1970, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); + // Note: hour/minute may not be parsed correctly by DateTime + } catch (\Horde_Date_Exception $e) { + // DateTime may reject this format - that's acceptable + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + } + } + + /** + * Test edge cases around the 8-digit boundary + * + * Documents behavior of 7-digit, 8-digit, and 9-digit numeric strings. + * + * @link https://github.com/horde/Date/pull/8 + */ + public function testNumericStringLengthBoundaries(): void + { + // 7 digits - too short for YYYYMMDD or timestamp string BC + // Falls through to DateTime parsing + try { + $date = new Horde_Date('1970101'); // 7 digits + // DateTime may parse this in unexpected ways or reject it + $this->assertTrue(true); // Just document that it doesn't crash + } catch (\Horde_Date_Exception $e) { + // Rejection is acceptable + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + } + + // 8 digits with valid YYYYMMDD - handled as date + $date = new Horde_Date('19700101'); + $this->assertEquals(1970, $date->year); + $this->assertEquals(1, $date->month); + $this->assertEquals(1, $date->mday); + + // 9 digits - handled as Unix timestamp (deprecated BC) + $date = new Horde_Date('946684800'); // 9 digits + $this->assertEquals(2000, $date->year); // Timestamp interpretation + + // 11 digits - handled as Unix timestamp + $date = new Horde_Date('17739446699'); // 11 digits (far future) + $this->assertTrue($date->year > 2500); // Timestamp interpretation + + // 12 digits - NOT handled by timestamp BC, falls to explicit regex/DateTime + $date = new Horde_Date('200102030405'); // 12 digits + // Behavior depends on DateTime + $this->assertTrue($date->year >= 1970); } }