diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2a47ae6e..f87c189e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -144,7 +144,7 @@ jobs: - uses: actions/checkout@v6 - name: Get composer cache directory - run: echo COMPOSER_CACHE="$(composer config cache-files-dir)" >> "$env:GITHUB_ENV" + run: echo COMPOSER_CACHE="$(composer config cache-files-dir)" >> "$GITHUB_ENV" - name: Get COMPOSER_ROOT_VERSION from composer.json branch alias shell: bash @@ -154,7 +154,7 @@ jobs: echo "Could not read extra.branch-alias.dev-master from composer.json" >&2 exit 1 fi - echo "COMPOSER_ROOT_VERSION=$ROOT_VERSION" >> "GITHUB_ENV" + echo COMPOSER_ROOT_VERSION="$ROOT_VERSION" >> "$GITHUB_ENV" - name: Cache composer dependencies uses: actions/cache@v5 diff --git a/composer.json b/composer.json index 4be50fcc..9a16784f 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "simplesamlphp/composer-xmlprovider-installer": "~1.3" }, "require-dev": { - "simplesamlphp/simplesamlphp-test-framework": "~1.11" + "simplesamlphp/simplesamlphp-test-framework": "~1.11.5" }, "support": { "issues": "https://github.com/simplesamlphp/xml-common/issues", diff --git a/src/XML/Assert/DateTimeTrait.php b/src/XML/Assert/DateTimeTrait.php index ffe8b1db..08616440 100644 --- a/src/XML/Assert/DateTimeTrait.php +++ b/src/XML/Assert/DateTimeTrait.php @@ -31,15 +31,6 @@ trait DateTimeTrait * * '.' s+ (if present) represents the fractional seconds; * * zzzzzz (if present) represents the timezone (as described below). * - * Except for trailing fractional zero digits in the seconds representation, '24:00:00' time representations, - * and timezone (for timezoned values), the mapping from literals to values is one-to-one. - * Where there is more than one possible representation, the canonical representation is as follows: - - * The 2-digit numeral representing the hour must not be '24'; - * The fractional second string, if present, must not end in '0'; - * for timezoned values, the timezone must be represented with 'Z' (All timezoned dateTime values are UTC.). - * - * * Note: we're restricting decimal seconds to 12, although strictly the standards allow an infite number. * * We know for a fact that Apereo CAS v7.0.x uses 9 decimals @@ -65,7 +56,7 @@ trait DateTimeTrait ([0-1][0-9]|2[0-3]) :(0[0-9]|[1-5][0-9]) :(0[0-9]|[1-5][0-9]) - (\.[0-9]{0,11}[1-9])? + (\.[0-9]{0,12})? ( [+-]([0-1][0-9]|2[0-4]) :(0[0-9]|[1-5][0-9]) diff --git a/src/XML/Assert/TimeTrait.php b/src/XML/Assert/TimeTrait.php index 121d4de1..e72c4000 100644 --- a/src/XML/Assert/TimeTrait.php +++ b/src/XML/Assert/TimeTrait.php @@ -24,14 +24,6 @@ trait TimeTrait * * '.' s+ (if present) represents the fractional seconds; * * zzzzzz (if present) represents the timezone (as described below). * - * Except for trailing fractional zero digits in the seconds representation, '24:00:00' time representations, - * and timezone (for timezoned values), the mapping from literals to values is one-to-one. - * Where there is more than one possible representation, the canonical representation is as follows: - * - * The 2-digit numeral representing the hour must not be '24'; - * The fractional second string, if present, must not end in '0'; - * for timezoned values, the timezone must be represented with 'Z' (All timezoned dateTime values are UTC.). - * * Note: we're restricting decimal seconds to 12, although strictly the standards allow an infite number. * * We know for a fact that Apereo CAS v7.0.x uses 9 decimals @@ -41,7 +33,7 @@ trait TimeTrait : (0[0-9]|[1-5][0-9]) :(0[0-9]|[1-5][0-9]) - (\.[0-9]{0,11}[1-9])? + (\.[0-9]{0,12})? ([+-]([0-1][0-9]|2[0-4]):(0[0-9]|[1-5][0-9])|Z)? $/Dx'; diff --git a/src/XMLSchema/Type/DateTimeValue.php b/src/XMLSchema/Type/DateTimeValue.php index 06080cbc..6b6fe745 100644 --- a/src/XMLSchema/Type/DateTimeValue.php +++ b/src/XMLSchema/Type/DateTimeValue.php @@ -11,6 +11,8 @@ use SimpleSAML\XMLSchema\Exception\SchemaViolationException; use SimpleSAML\XMLSchema\Type\Interface\AbstractAnySimpleType; +use function preg_replace; + /** * @package simplesaml/xml-common */ @@ -18,7 +20,7 @@ class DateTimeValue extends AbstractAnySimpleType { public const string SCHEMA_TYPE = 'dateTime'; - public const string DATETIME_FORMAT = 'Y-m-d\\TH:i:sP'; + public const string DATETIME_FORMAT = 'Y-m-d\\TH:i:s.uP'; /** @@ -28,7 +30,12 @@ class DateTimeValue extends AbstractAnySimpleType */ protected function sanitizeValue(string $value): string { - return static::collapseWhitespace(static::normalizeWhitespace($value)); + $normalized = static::collapseWhitespace(static::normalizeWhitespace($value)); + $sanitized = preg_replace('/\.(\d{0,6})\d*/', '.$1', $normalized); + + // Remove all trailing zeros after the dot, and remove the dot if only zeros were present + $sanitized = preg_replace('/\.(?=\d)(?:\d*?[1-9])?\K0+(?=[^0-9]|$)/', '', $sanitized); + return preg_replace('/\.(?!\d)/', '', $sanitized); } diff --git a/src/XMLSchema/Type/TimeValue.php b/src/XMLSchema/Type/TimeValue.php index 7b0087e1..84e57039 100644 --- a/src/XMLSchema/Type/TimeValue.php +++ b/src/XMLSchema/Type/TimeValue.php @@ -4,10 +4,15 @@ namespace SimpleSAML\XMLSchema\Type; +use DateTimeImmutable; +use DateTimeInterface; +use Psr\Clock\ClockInterface; use SimpleSAML\XML\Assert\Assert; use SimpleSAML\XMLSchema\Exception\SchemaViolationException; use SimpleSAML\XMLSchema\Type\Interface\AbstractAnySimpleType; +use function preg_replace; + /** * @package simplesaml/xml-common */ @@ -15,6 +20,8 @@ class TimeValue extends AbstractAnySimpleType { public const string SCHEMA_TYPE = 'time'; + public const string DATETIME_FORMAT = 'H:i:s.uP'; + /** * Sanitize the value. @@ -23,7 +30,12 @@ class TimeValue extends AbstractAnySimpleType */ protected function sanitizeValue(string $value): string { - return static::collapseWhitespace(static::normalizeWhitespace($value)); + $normalized = static::collapseWhitespace(static::normalizeWhitespace($value)); + $sanitized = preg_replace('/\.(\d{0,6})\d*/', '.$1', $normalized); + + // Remove all trailing zeros after the dot, and remove the dot if only zeros were present + $sanitized = preg_replace('/\.(?=\d)(?:\d*?[1-9])?\K0+(?=[^0-9]|$)/', '', $sanitized); + return preg_replace('/\.(?!\d)/', '', $sanitized); } @@ -37,4 +49,29 @@ protected function validateValue(string $value): void { Assert::validTime($this->sanitizeValue($value), SchemaViolationException::class); } + + + /** + */ + public static function now(ClockInterface $clock): static + { + return static::fromDateTime($clock->now()); + } + + + /** + * @param \DateTimeInterface $value + */ + public static function fromDateTime(DateTimeInterface $value): static + { + return new static($value->format(static::DATETIME_FORMAT)); + } + + + /** + */ + public function toDateTime(): DateTimeImmutable + { + return new DateTimeImmutable($this->getValue()); + } } diff --git a/tests/XML/Assert/DateTimeTest.php b/tests/XML/Assert/DateTimeTest.php index 00881d6c..a4076070 100644 --- a/tests/XML/Assert/DateTimeTest.php +++ b/tests/XML/Assert/DateTimeTest.php @@ -49,6 +49,7 @@ public static function provideValidDateTime(): array 'valid with subseconds' => [true, '2001-10-26T21:32:52.12679'], 'valid with more than four digit year' => [true, '-22001-10-26T21:32:52+02:00'], 'valid with up to twelve sub-seconds' => [true, '2001-10-26T21:32:52.126798764382'], + 'sub-seconds ending with zero' => [true, '2001-10-26T21:32:52.12670'], ]; } @@ -67,7 +68,6 @@ public static function provideInvalidDateTime(): array 'prefixed zero' => [false, '02001-10-26T25:32:52+02:00'], 'wrong format' => [false, '01-10-26T21:32'], 'too many sub-seconds' => [false, '2001-10-26T21:32:52.1267987643821'], - 'sub-seconds ending with zero' => [false, '2001-10-26T21:32:52.12670'], ]; } } diff --git a/tests/XML/Assert/TimeTest.php b/tests/XML/Assert/TimeTest.php index 335df610..a70152a5 100644 --- a/tests/XML/Assert/TimeTest.php +++ b/tests/XML/Assert/TimeTest.php @@ -47,6 +47,7 @@ public static function provideValidTime(): array 'valid time with 00:00 timezone' => [true, '19:32:52+00:00'], 'valid time with sub-seconds' => [true, '21:32:52.12679'], 'valid with up to twelve sub-seconds' => [true, '21:32:52.126798764382'], + 'sub-seconds ending with zero' => [true, '21:32:52.12670'], ]; } @@ -62,7 +63,6 @@ public static function provideInvalidTime(): array 'invalid hour twenty-four' => [false, '24:25:10'], 'invalid invalid format' => [false, '1:20:10'], 'too many sub-seconds' => [false, '21:32:52.1267987643821'], - 'sub-seconds ending with zero' => [false, '21:32:52.12670'], ]; } } diff --git a/tests/XMLSchema/Type/DateTimeValueTest.php b/tests/XMLSchema/Type/DateTimeValueTest.php index 8b7d5360..a8e18593 100644 --- a/tests/XMLSchema/Type/DateTimeValueTest.php +++ b/tests/XMLSchema/Type/DateTimeValueTest.php @@ -54,6 +54,36 @@ public function testFromDateTime(): void } + /** + */ + public function testSubSeconds(): void + { + // Strip sub-second trailing zero's and make sure the decimal sign is removed + $dateTimeValue = DateTimeValue::fromString('2001-10-26T21:32:52.00'); + $this->assertEquals('2001-10-26T21:32:52', $dateTimeValue->getValue()); + + // Strip sub-second trailing zero's + $dateTimeValue = DateTimeValue::fromString('2001-10-26T21:32:52.12300'); + $this->assertEquals('2001-10-26T21:32:52.123', $dateTimeValue->getValue()); + + // Strip sub-seconds over microsecond precision + $dateTimeValue = DateTimeValue::fromString('2001-10-26T21:32:52.1234567'); + $this->assertEquals('2001-10-26T21:32:52.123456', $dateTimeValue->getValue()); + + // Strip sub-second trailing zero's and make sure the decimal sign is removed + $dateTimeValue = DateTimeValue::fromString('2001-10-26T21:32:52.00Z'); + $this->assertEquals('2001-10-26T21:32:52Z', $dateTimeValue->getValue()); + + // Strip sub-seconds over microsecond precision with timezone + $dateTimeValue = DateTimeValue::fromString('2001-10-26T21:32:52.1234567+01:00'); + $this->assertEquals('2001-10-26T21:32:52.123456+01:00', $dateTimeValue->getValue()); + + // Strip sub-seconds over microsecond precision with timezone Zulu + $dateTimeValue = DateTimeValue::fromString('2001-10-26T21:32:52.1234567Z'); + $this->assertEquals('2001-10-26T21:32:52.123456Z', $dateTimeValue->getValue()); + } + + /** * @return array */ @@ -61,6 +91,12 @@ public static function provideValidDateTime(): array { return [ 'whitespace collapse' => [true, ' 2001-10-26T21:32:52 '], + 'trailing sub-second zero' => [true, '2001-10-26T21:32:52.1230'], + 'trailing sub-second zero with timezone' => [true, '2001-10-26T21:32:52.1230+00:00'], + 'trailing sub-second zero with timezone Zulu' => [true, '2001-10-26T21:32:52.1230Z'], + 'all trailing sub-second zero' => [true, '2001-10-26T21:32:52.00'], + 'all trailing sub-second zero with timezone' => [true, '2001-10-26T21:32:52.00+00:00'], + 'all trailing sub-second zero with timezone Zulu' => [true, '2001-10-26T21:32:52.00Z'], ]; } diff --git a/tests/XMLSchema/Type/TimeValueTest.php b/tests/XMLSchema/Type/TimeValueTest.php index 61b7fb38..0746ff92 100644 --- a/tests/XMLSchema/Type/TimeValueTest.php +++ b/tests/XMLSchema/Type/TimeValueTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\XMLSchema\Type\Builtin; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProviderExternal; @@ -40,6 +41,49 @@ public function testTime(bool $shouldPass, string $time): void } + /** + * Test the fromDateTime function + */ + #[DependsOnClass(TimeTest::class)] + public function testFromDateTime(): void + { + $t = new DateTimeImmutable('00:00:00+00:00'); + + $timeValue = TimeValue::fromDateTime($t); + $this->assertEquals('00:00:00+00:00', $timeValue->getValue()); + } + + + /** + */ + public function testSubSeconds(): void + { + // Strip sub-second trailing zero's and make sure the decimal sign is removed + $timeValue = TimeValue::fromString('21:32:52.00'); + $this->assertEquals('21:32:52', $timeValue->getValue()); + + // Strip sub-second trailing zero's + $timeValue = TimeValue::fromString('21:32:52.12300'); + $this->assertEquals('21:32:52.123', $timeValue->getValue()); + + // Strip sub-seconds over microsecond precision + $timeValue = TimeValue::fromString('21:32:52.1234567'); + $this->assertEquals('21:32:52.123456', $timeValue->getValue()); + + // Strip sub-second trailing zero's and make sure the decimal sign is removed + $timeValue = TimeValue::fromString('21:32:52.00Z'); + $this->assertEquals('21:32:52Z', $timeValue->getValue()); + + // Strip sub-seconds over microsecond precision with timezone + $timeValue = TimeValue::fromString('21:32:52.1234567+01:00'); + $this->assertEquals('21:32:52.123456+01:00', $timeValue->getValue()); + + // Strip sub-seconds over microsecond precision with timezone Zulu + $timeValue = TimeValue::fromString('21:32:52.1234567Z'); + $this->assertEquals('21:32:52.123456Z', $timeValue->getValue()); + } + + /** * @return array */ @@ -47,6 +91,12 @@ public static function provideValidTime(): array { return [ 'whitespace collapse' => [true, "\n 21:32:52.12679\t "], + 'trailing sub-second zero' => [true, '21:32:52.1230'], + 'trailing sub-second zero with timezone' => [true, '21:32:52.1230+00:00'], + 'trailing sub-second zero with timezone Zulu' => [true, '21:32:52.1230Z'], + 'all trailing sub-second all zero' => [true, '21:32:52.00'], + 'all trailing sub-second all zero with timezone' => [true, '21:32:52.00+00:00'], + 'all trailing sub-second all zero with timezone Zulu' => [true, '21:32:52.00Z'], ]; }