1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal\Validation;
5:
6: use om\ICal\Calendar;
7: use om\ICal\Component;
8: use om\ICal\Exception\InvalidRecurrenceRuleException;
9: use om\ICal\Exception\ValidationException;
10: use om\ICal\Property;
11: use om\ICal\Value\DateTimeValue;
12: use om\ICal\Value\ValueParser;
13: use om\RRule\Rule;
14:
15: /**
16: * Checks RFC 5545 semantics of a parsed calendar. The parser does not validate, so
17: * calendars with problems can still be read; use the validator to find them.
18: *
19: * $issues = (new Validator())->validate($calendar);
20: */
21: final class Validator {
22: /**
23: * Properties that must not occur more than once (RFC 5545, section 3.6, RFC 9073 and RFC 9074).
24: *
25: * @internal
26: */
27: public const array SINGLE = [
28: 'VCALENDAR' => ['PRODID', 'VERSION', 'CALSCALE', 'METHOD'],
29: 'VEVENT' => ['DTSTAMP', 'UID', 'DTSTART', 'CLASS', 'CREATED', 'DESCRIPTION', 'GEO', 'LAST-MODIFIED', 'LOCATION', 'ORGANIZER', 'PRIORITY', 'SEQUENCE', 'STATUS', 'SUMMARY', 'TRANSP', 'URL', 'RECURRENCE-ID', 'DTEND', 'DURATION'],
30: 'VTODO' => ['DTSTAMP', 'UID', 'CLASS', 'COMPLETED', 'CREATED', 'DESCRIPTION', 'DTSTART', 'GEO', 'LAST-MODIFIED', 'LOCATION', 'ORGANIZER', 'PERCENT-COMPLETE', 'PRIORITY', 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'URL', 'DUE', 'DURATION'],
31: 'VJOURNAL' => ['DTSTAMP', 'UID', 'CLASS', 'CREATED', 'DTSTART', 'LAST-MODIFIED', 'ORGANIZER', 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'URL'],
32: 'VFREEBUSY' => ['DTSTAMP', 'UID', 'CONTACT', 'DTSTART', 'DTEND', 'ORGANIZER', 'URL'],
33: 'VTIMEZONE' => ['TZID', 'LAST-MODIFIED', 'TZURL'],
34: 'VALARM' => ['ACTION', 'TRIGGER', 'DURATION', 'REPEAT', 'DESCRIPTION', 'SUMMARY', 'UID', 'ACKNOWLEDGED'],
35: 'VLOCATION' => ['UID', 'DESCRIPTION', 'GEO', 'LOCATION-TYPE', 'NAME'],
36: ];
37:
38: /** Required properties. */
39: private const array REQUIRED = [
40: 'VCALENDAR' => ['PRODID', 'VERSION'],
41: 'VEVENT' => ['DTSTAMP', 'UID'],
42: 'VTODO' => ['DTSTAMP', 'UID'],
43: 'VJOURNAL' => ['DTSTAMP', 'UID'],
44: 'VFREEBUSY' => ['DTSTAMP', 'UID'],
45: 'VTIMEZONE' => ['TZID'],
46: 'STANDARD' => ['DTSTART', 'TZOFFSETTO', 'TZOFFSETFROM'],
47: 'DAYLIGHT' => ['DTSTART', 'TZOFFSETTO', 'TZOFFSETFROM'],
48: 'VALARM' => ['ACTION', 'TRIGGER'],
49: 'VLOCATION' => ['UID'],
50: ];
51:
52: /** Properties with a DATE-TIME value in UTC (RFC 9074). */
53: private const array UTC = ['ACKNOWLEDGED' => true];
54:
55: /** @var list<Issue> */
56: private array $issues = [];
57: private ValueParser $values;
58: /** @var array<string, true> */
59: private array $definedTimezones = [];
60:
61: /**
62: * @return list<Issue>
63: */
64: public function validate(Calendar $calendar): array {
65: $this->issues = [];
66: $this->values = new ValueParser($calendar->component, $calendar->timezoneResolver());
67: $this->definedTimezones = [];
68: foreach ($calendar->timezones() as $timezone) {
69: $tzid = $timezone->tzid();
70: if ($tzid !== null) {
71: $this->definedTimezones[$tzid] = true;
72: }
73: }
74: $this->component($calendar->component, null);
75: return $this->issues;
76: }
77:
78: /**
79: * @throws ValidationException with the first error
80: */
81: public function assertValid(Calendar $calendar): void {
82: foreach ($this->validate($calendar) as $issue) {
83: if ($issue->severity === Severity::Error) {
84: throw ValidationException::create($issue->code, $issue->message, $issue->line, $issue->property);
85: }
86: }
87: }
88:
89: private function component(Component $component, ?string $parent): void {
90: $uid = $component->property('UID')?->value;
91: foreach (self::REQUIRED[$component->name] ?? [] as $name) {
92: if (!$component->has($name)) {
93: $this->issue(Severity::Error, 'component.missing-property', "$name is required.", $component, $name, uid: $uid);
94: }
95: }
96: foreach (self::SINGLE[$component->name] ?? [] as $name) {
97: $properties = $component->properties($name);
98: if (count($properties) > 1) {
99: $this->issue(Severity::Error, 'component.duplicate-property', "$name must not occur more than once.", $component, $name, $properties[1]->line, $uid);
100: }
101: }
102: if (count($component->properties('RRULE')) > 1) {
103: $this->issue(Severity::Warning, 'recurrence.multiple-rrule', 'RRULE should not occur more than once.', $component, 'RRULE', $component->properties('RRULE')[1]->line, $uid);
104: }
105:
106: foreach ($component->properties as $property) {
107: $this->property($property, $component, $uid);
108: }
109:
110: match ($component->name) {
111: 'VEVENT' => $this->ends($component, 'DTEND', $uid),
112: 'VTODO' => $this->ends($component, 'DUE', $uid),
113: 'VTIMEZONE' => $component->components('STANDARD') === [] && $component->components('DAYLIGHT') === []
114: ? $this->issue(Severity::Error, 'timezone.no-observance', 'VTIMEZONE needs a STANDARD or DAYLIGHT component.', $component)
115: : null,
116: default => null,
117: };
118: if (in_array($component->name, ['VEVENT', 'VTODO', 'VJOURNAL'], true)) {
119: $this->recurrence($component, $uid);
120: }
121: if ($parent === 'VCALENDAR' && $component->name === 'VALARM') {
122: $this->issue(Severity::Error, 'alarm.outside-item', 'VALARM must be inside VEVENT or VTODO.', $component);
123: }
124:
125: foreach ($component->components as $child) {
126: $this->component($child, $component->name);
127: }
128: }
129:
130: private function property(Property $property, Component $component, ?string $uid): void {
131: if (isset(ValueParser::TYPES[$property->name]) || $property->parameter('VALUE') !== null) {
132: $value = $this->values->value($property);
133: if ($value === null || $value === []) {
134: $this->issue(Severity::Error, 'value.invalid', "Invalid value \"$property->value\".", $component, $property->name, $property->line, $uid);
135: } elseif (isset(self::UTC[$property->name]) && $value instanceof DateTimeValue && !$value->isUtc()) {
136: $this->issue(Severity::Warning, 'value.not-utc', "$property->name must be a UTC time.", $component, $property->name, $property->line, $uid);
137: }
138: }
139: $tzid = $property->parameter('TZID');
140: if ($tzid !== null) {
141: if (!isset($this->definedTimezones[$tzid])) {
142: $this->issue(Severity::Warning, 'timezone.not-defined', "TZID \"$tzid\" has no VTIMEZONE definition.", $component, $property->name, $property->line, $uid);
143: }
144: if ($this->values->timezone($tzid) === null) {
145: $this->issue(Severity::Warning, 'timezone.unresolved', "TZID \"$tzid\" cannot be resolved, its times are floating.", $component, $property->name, $property->line, $uid);
146: }
147: }
148: }
149:
150: /**
151: * DTEND (or DUE) and DURATION are exclusive, have the value type of DTSTART and are not earlier.
152: */
153: private function ends(Component $component, string $endName, ?string $uid): void {
154: $end = $component->property($endName);
155: $duration = $component->property('DURATION');
156: if ($end !== null && $duration !== null) {
157: $this->issue(Severity::Error, 'component.end-and-duration', "$endName and DURATION must not occur together.", $component, 'DURATION', $duration->line, $uid);
158: }
159: $start = $this->date($component->property('DTSTART'));
160: if ($duration !== null && $start === null) {
161: $this->issue(Severity::Error, 'component.duration-without-start', 'DURATION requires DTSTART.', $component, 'DURATION', $duration->line, $uid);
162: }
163: $endValue = $this->date($end);
164: if ($start === null || $endValue === null || $end === null) {
165: return;
166: }
167: if ($start->isDate() !== $endValue->isDate()) {
168: $this->issue(Severity::Error, 'component.end-type', "$endName must have the same value type as DTSTART.", $component, $endName, $end->line, $uid);
169: } elseif ($endValue->toDateTime(null, $start->timezone() ?? new \DateTimeZone('UTC')) < $start->toDateTime(null, $start->timezone() ?? new \DateTimeZone('UTC'))) {
170: $this->issue(Severity::Error, 'component.end-before-start', "$endName must not be earlier than DTSTART.", $component, $endName, $end->line, $uid);
171: }
172: }
173:
174: private function recurrence(Component $component, ?string $uid): void {
175: $start = $this->date($component->property('DTSTART'));
176: foreach ($component->properties('RRULE') as $property) {
177: $rule = $this->values->recur($property);
178: if ($rule === null) {
179: continue; // reported as an invalid value
180: }
181: if ($rule->rscale === null && preg_match('/(?:^|;)SKIP=/i', trim($property->value))) {
182: // the parser ignores SKIP, see ValueParser::recur()
183: $this->issue(Severity::Error, 'recurrence.skip-without-rscale', 'SKIP must not be present without RSCALE (RFC 7529, section 4).', $component, 'RRULE', $property->line, $uid);
184: }
185: try {
186: $rule->assertGregorian();
187: } catch (InvalidRecurrenceRuleException $e) {
188: $this->issue(Severity::Warning, $e->errorCode(), $e->getMessage(), $component, 'RRULE', $property->line, $uid);
189: }
190: if ($rule->count !== null && $rule->until !== null) {
191: $this->issue(Severity::Error, 'recurrence.count-and-until', 'COUNT and UNTIL must not occur together.', $component, 'RRULE', $property->line, $uid);
192: }
193: if ($start !== null && $rule->until !== null) {
194: $this->until($rule, $start, $component, $property, $uid);
195: }
196: }
197: if ($start === null && ($component->has('RRULE') || $component->has('RDATE'))) {
198: $this->issue(Severity::Error, 'recurrence.without-start', 'A recurring component requires DTSTART.', $component, 'RRULE', uid: $uid);
199: }
200: }
201:
202: /**
203: * UNTIL is a date for date DTSTART, UTC for UTC and zoned DTSTART, local for floating DTSTART.
204: */
205: private function until(Rule $rule, DateTimeValue $start, Component $component, Property $property, ?string $uid): void {
206: $until = strtoupper($property->value);
207: preg_match('/UNTIL=([0-9TZ]+)/', $until, $match);
208: $value = $match[1] ?? '';
209: $expected = match (true) {
210: $start->isDate() => strlen($value) === 8,
211: $start->isFloating() => strlen($value) === 15,
212: default => str_ends_with($value, 'Z'),
213: };
214: if (!$expected) {
215: $this->issue(Severity::Error, 'recurrence.until-type', 'UNTIL must have the value type of DTSTART (UTC for zoned times).', $component, 'RRULE', $property->line, $uid);
216: }
217: }
218:
219: private function date(?Property $property): ?DateTimeValue {
220: return $property === null ? null : $this->values->dateTime($property);
221: }
222:
223: private function issue(Severity $severity, string $code, string $message, Component $component, ?string $property = null, ?int $line = null, ?string $uid = null): void {
224: $this->issues[] = new Issue($severity, $code, $message, $component->name, $property, $line, $uid);
225: }
226: }
227: