Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qtparsetimezone.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3// Qt-Security score:critical reason:data-parser
4#include "private/qtparsetimezone_p.h"
5
6#include "qdatetime.h"
7#include "qlocale.h"
8#include "private/qlocale_p.h"
9#include <QtCore/qloggingcategory.h>
10#include "qstring.h"
11#include "private/qtenvironmentvariables_p.h" // for tzName()
12#include "qtimezone.h"
13#if QT_CONFIG(timezone)
14# include "private/qtimezoneprivate_p.h"
15#endif
16
18
19using namespace Qt::StringLiterals;
20
21namespace {
22
23QList<QtParseTimeZone::ParsedZone>
24addMatch(QList<QtParseTimeZone::ParsedZone> &&matches,
25 QtParseTimeZone::ParsedZone &&match, [[maybe_unused]] QStringView tail)
26{
27 // Input matches is sorted with x before y when isBetter(x, y); add our new
28 // entry just after the last that isBetter(than, match).
29 using namespace QtParseTimeZone;
30
31 // How discerning isBetter() can be depends on whether zones can have backends.
32 const auto isBetter = [
33#if QT_CONFIG(timezone)
34 tail,
35#endif
36 newAddr = &match] (const ParsedZone &left, const ParsedZone &right) {
37 Q_ASSERT(left.startIndex == right.startIndex);
38 if (left.endIndex > right.endIndex)
39 return true;
40 if (left.endIndex < right.endIndex)
41 return false;
42 // That leaves matches of equal length, which may have interpreted the
43 // same thing in different ways, most likely equivalent. We do have some
44 // preferences among these, though.
45 const auto preferTruer = [](bool goodL, bool goodR) -> std::optional<bool> {
46 if (goodL != goodR)
47 return goodL;
48 return {};
49 };
50 const Qt::TimeSpec leftSpec = left.zone.timeSpec(), rightSpec = right.zone.timeSpec();
51 // UTC and fixed-offset time representations are particularly nice:
52 if (const auto pref = preferTruer(QTimeZone::isUtcOrFixedOffset(leftSpec),
53 QTimeZone::isUtcOrFixedOffset(rightSpec))) {
54 return *pref;
55 }
56 // For historical reasons (e.g. QTBUG-114575) we prefer local time over
57 // the local zone's representation as a zone:
58 if (const auto pref = preferTruer(leftSpec == Qt::LocalTime, rightSpec == Qt::LocalTime))
59 return *pref;
60#if QT_CONFIG(timezone)
61 // A zone whose ID is exactly the text matched is also clearly better:
62 if (const auto pref = preferTruer(leftSpec == Qt::TimeZone
63 && tail.startsWith(QLatin1String(left.zone.id())),
64 rightSpec == Qt::TimeZone
65 && tail.startsWith(QLatin1String(right.zone.id())))) {
66 return *pref;
67 }
68#endif
69 // All other things being equal, prefer entries already in the list over match:
70 return &right == newAddr;
71 };
72 const auto pos = std::upper_bound(matches.begin(), matches.end(), match, isBetter);
73 // Could condition the following on match not being a duplicate of pos[-1],
74 // for pos != begin(), but hopefully we simply aren't sending duplicates
75 // this way, anyway.
76 matches.insert(pos, match);
77 return std::move(matches);
78}
79
80#if QT_CONFIG(timezone)
81constexpr char zoneNamePunctuation[] = "+-./:_";
82
83QDateTimePrivate::DaylightStatus timeTypeToStatus(QTimeZone::TimeType type) {
84 using QDTP = QDateTimePrivate;
85 switch (type) {
86 case QTimeZone::GenericTime: return QDTP::UnknownDaylightTime;
87 case QTimeZone::StandardTime: return QDTP::StandardTime;
88 case QTimeZone::DaylightTime: return QDTP::DaylightTime;
89 }
90 Q_UNREACHABLE_RETURN(QDTP::UnknownDaylightTime);
91}
92
93auto matchIanaId(QStringView text)
94{
95 struct R {
96 QTimeZone zone;
97 qsizetype length = 0;
98 operator bool() const noexcept { return length > 0; }
99 };
100 // Collect up plausibly-valid characters; let QTimeZone work out what's
101 // truly valid.
102 const auto invalidZoneNameCharacter = [] (const QChar &c) {
103 static constexpr auto matcher = QtPrivate::makeCharacterSetMatch<zoneNamePunctuation>();
104 const auto cu = c.unicode();
105 return cu >= 127u || !(matcher.matches(uchar(cu)) || c.isLetterOrNumber());
106 };
107 qsizetype index = std::distance(text.cbegin(), std::find_if(text.cbegin(), text.cend(),
108 invalidZoneNameCharacter));
109 if (!index)
110 return R{};
111 Q_ASSERT(index <= text.size());
112 text.truncate(index);
113
114 // Limit name fragments (between slashes) to 20 characters.
115 // (Valid time-zone IDs are allowed up to 14 and Android has quirks up to 17.)
116 constexpr qsizetype MaxFragmentLength = 20;
117 // Limit number of fragments to six; no known zone name has more than four.
118 constexpr int MaxFragmentIndex = 5;
119 qsizetype lastSlash = -1;
120 int fragment = 1;
121 while (lastSlash < index) {
122 const qsizetype newToken = lastSlash + 1;
123 qsizetype slash = text.indexOf(u'/', newToken);
124 if (slash < 0)
125 slash = index; // i.e. the end of the candidate text
126 else if (++fragment > MaxFragmentIndex)
127 index = slash; // Truncate
128 if (slash - newToken > MaxFragmentLength)
129 index = newToken + MaxFragmentLength; // Truncate
130 // If any of those conditions was met, index <= slash, so this exits the loop:
131 lastSlash = slash;
132 }
133 // Only ASCII characters are valid, so we can now convert to Latin1.
134 QByteArray name = text.first(index).toLatin1();
135 // Subsequent truncation won't trigger reallocation, so is efficient despite
136 // the owning container.
137
138 // IANA includes a limited few three-letter abbreviations as IDs.
139 // Find longest IANA ID match:
140 for (; index >= 3; name.truncate(--index)) {
141 QTimeZone zone(name);
142 if (zone.isValid())
143 return R{zone, index};
144 }
145
146 // Not a known IANA ID.
147 return R{};
148}
149#endif // feature timezone
150
151auto matchSystemName(QStringView text, const QLocale &locale)
152{
153 using QDTP = QDateTimePrivate;
154 struct R {
155 qsizetype length = 0;
156 QDTP::DaylightStatus season = QDTP::UnknownDaylightTime;
157 operator bool() const noexcept { return length > 0; }
158 } best;
159 qTzSet();
160 // On MS-Win, at least when system zone is UTC, qTzName() can return empty.
161 for (int i = 0; i < 2; ++i) {
162 const QString zone(qTzName(i));
163 if (zone.size() > best.length && text.startsWith(zone))
164 best = { zone.size(), i ? QDTP::DaylightTime : QDTP::StandardTime };
165 }
166#if QT_CONFIG(timezone)
167 // Mimic each candidate QLocale::toString() could have used, to ensure round-trips work:
168 const auto consider = [text, &best](QStringView zone, QDTP::DaylightStatus season) {
169 if (text.startsWith(zone)) {
170 // UTC-based zone's displayName() only includes minutes if non-zero:
171 constexpr qsizetype utcSignHourWidth = 6, withMinutesWidth = 9;
172 if (withMinutesWidth > best.length && zone.size() == utcSignHourWidth
173 && zone.startsWith("UTC"_L1)
174 && text.sliced(utcSignHourWidth).startsWith(":00"_L1)) {
175 best = { withMinutesWidth, QDTP::UnknownDaylightTime };
176 } else if (zone.size() > best.length) {
177 best = { zone.size(), season };
178 }
179 }
180 };
181 /* QLocale::toString would skip this if locale == QLocale::system(), but we
182 might not be using the same system locale as whoever generated the text
183 we're parsing. So consider it anyway. */
184 if (const QTimeZone sys = QTimeZone::systemTimeZone(); sys.hasDaylightTime()) {
185 constexpr QTimeZone::TimeType types[] = {
186 QTimeZone::GenericTime, QTimeZone::StandardTime, QTimeZone::DaylightTime };
187 for (const auto timeType : types) {
188 consider(sys.displayName(timeType, QTimeZone::ShortName, locale),
189 timeTypeToStatus(timeType));
190 }
191 } else {
192 consider(sys.displayName(QTimeZone::GenericTime, QTimeZone::ShortName, locale),
193 QDTP::UnknownDaylightTime);
194 }
195#else
196 Q_UNUSED(locale);
197#endif
198 return best;
199}
200
201struct SizeOffset {
202 qsizetype length = 0;
203 int secondsEast = 0;
204 constexpr SizeOffset(qsizetype size, int offset) : length(size), secondsEast(offset) {}
205};
206
207// Locale-independent ISO 8601 offset forms
208QList<SizeOffset> matchIso8601(QStringView text, QtTemporalPattern::TemporalFieldFlags flags)
209{
210 constexpr int MaxOffsetHours
211 = (std::max)(-QTimeZone::MinUtcOffsetSecs, QTimeZone::MaxUtcOffsetSecs) / 3600;
212 QList<SizeOffset> matches;
213 using namespace QtTemporalPattern;
214 using namespace FieldGroup;
215 using Flag = TemporalFieldFlag;
216
217 if (flags.testFlag(Flag::AllowZSuffix) && text.startsWith(QLatin1Char('Z'))) {
218 matches.emplace_back(1, 0);
219 // No other ISO 8601 offset form starts with Z.
220 return matches;
221 }
222
223 qsizetype used = 0;
224 QStringView tail = text; // Invariant: is a prefix of text.sliced(used)
225 if (tail.startsWith(u"UTC")) {
226 if (!matchesFlagWithin(flags, Flag::AcceptUtcPrefix, UtcPrefixMask))
227 return matches;
228 used += 3;
229 tail = tail.sliced(3);
230 } else if (!matchesFlagWithin(flags, Flag::NeedNoUtcPrefix, UtcPrefixMask)) {
231 return matches;
232 }
233 const bool negate = tail.startsWith(u'-');
234 if (!negate && !tail.startsWith(u'+')) {
235 if (used) // Starts with "UTC", which is a match:
236 matches.emplace_back(used, 0);
237 return matches;
238 }
239 ++used;
240 tail = tail.sliced(1);
241
242 if (tail.isEmpty())
243 return matches;
244
245 const auto extend = [&matches, negate](qsizetype length, int secondsEast) {
246 if (negate)
247 secondsEast = -secondsEast;
248 if (secondsEast >= QTimeZone::MinUtcOffsetSecs
249 && secondsEast <= QTimeZone::MaxUtcOffsetSecs) {
250 matches.emplace_back(length, secondsEast);
251 }
252 };
253
254 int hours = 0, minutes = 0, seconds = 0;
255 const bool zeroPad = flags.testFlag(Flag::ZeroPad);
256 constexpr TemporalFieldFlags WithColon = Flag::Verbal | Flag::Standalone;
257 qsizetype colon = tail.indexOf(u':');
258 if (colon == 0) // No digits in (first field of) offset.
259 return matches;
260
261 if (!matchesFlagsWithin(flags, WithColon, FormMask)) { // Colon forbidden.
262 if (colon > 0) {
263 // Treat as juxtaposed fields with cruft starting at the colon:
264 tail = tail.first(colon);
265 colon = -1;
266 }
267 } else if (!matchesFlagWithin(flags, Flag::Numeric, FormMask)) { // Colon required
268 if (colon > 2) {
269 // Too long for a single field. Treat as hour field followed by trailing
270 // cruft, since our colon is too late to separate it from a later field.
271 tail = tail.first(2);
272 colon = -1; // There is no longer a colon in tail.
273 } else if (colon < 0) {
274 // Lack of expected colon - we have, at most, an hour field:
275 if (tail.size() > 2)
276 tail = tail.first(2);
277 }
278 } // else: if a colon is there, read fields up to it.
279 // If we have a colon at the end of the hour field, each field must end in a
280 // colon. No field is wider than two digits, so a colon further out than
281 // that isn't the end of the hour field, just part of some dangling cruft.
282 const bool hasColon = colon > 0 && colon <= 2;
283 bool ok;
284 qsizetype fieldUsed = qMin(2, hasColon ? colon : tail.size());
285 hours = tail.first(fieldUsed).toInt(&ok);
286 if (!ok || hours > MaxOffsetHours || (zeroPad && fieldUsed < 2)) {
287 if (zeroPad) // Hour field must have full width.
288 return matches;
289 hours = tail.first(1).toInt(&ok);
290 fieldUsed = 1;
291 // Single-digit hour is only allowed in colon-separated form; if we
292 // don't have an actual colon, the parse must end after this field.
293 if (!ok)
294 return matches;
295 }
296 tail = tail.sliced(fieldUsed);
297 used += fieldUsed;
298
299 qsizetype fieldEnd[3] = { used, 0, 0 };
300 int fieldsSeen = 1; // Seen hour field
301 // If we're allowed more than just hour, see what we've got:
302 if ((flags & WidthMask) != QtTemporalPattern::TemporalFieldFlags{Flag::Narrow}) {
303 for (int i = 0; i < 2 && fieldUsed && !tail.isEmpty(); ++i) {
304 QStringView digits = tail;
305 qsizetype sepLen = 0;
306 if (hasColon || fieldUsed == 1) {
307 if (fieldUsed != colon)
308 break;
309 Q_ASSERT(tail.startsWith(u':'));
310 digits = digits.sliced(1);
311 sepLen = 1;
312 }
313 int &field = i ? seconds : minutes;
314 colon = hasColon && !i ? digits.indexOf(u':') : -1;
315 if (colon == 0) // Empty field
316 break;
317 if ((colon == -1 ? digits.size() : colon) < 2) // Not enough digits for field.
318 break;
319 field = digits.first(2).toInt(&ok);
320 if (!ok)
321 break;
322 fieldUsed = 2; // So next iteration sees that to compare to colon.
323 tail = tail.sliced(sepLen + fieldUsed);
324 used += sepLen + fieldUsed;
325 fieldEnd[fieldsSeen++] = used;
326 // Quit loop after 1st iteration unless accepting seconds field:
327 if (!i && !matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask))
328 break;
329 }
330 }
331 // Check we got enough fields, add entries, with longer matches earlier:
332 switch (fieldsSeen) {
333 case 3: // Would have exited loop early unless:
334 Q_ASSERT(matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask));
335 // TODO: if Wide, check for fractional part.
336 extend(fieldEnd[--fieldsSeen], (hours * 60 + minutes) * 60 + seconds);
337 Q_FALLTHROUGH();
338 case 2: // Hour and minute supplied.
339 if (!zeroPad || matchesFlagWithin(flags, Flag::Abbreviated, WidthMask))
340 extend(fieldEnd[fieldsSeen - 1], (hours * 60 + minutes) * 60);
341 --fieldsSeen;
342 Q_FALLTHROUGH();
343 case 1: // Only hour supplied: need Narrow if ZeroPad:
344 if (zeroPad && !matchesFlagWithin(flags, Flag::Narrow, WidthMask))
345 break;
346 extend(fieldEnd[--fieldsSeen], hours * 60 * 60);
347 }
348 return matches;
349}
350
351}
352
354
355/*!
356 \internal
357 \since 6.12
358 \namespace QtParseTimeZone
359 \brief A toolset for parsing time zone identification strings
360
361 A time zone may be identified by an offset from UTC or, in various ways, by
362 a name. This namespace provides a \l {QtParseTimeZone::}{prefix()} function
363 to parse an initial portion of a string as such an identifier, controlled by
364 configuration options provided by \l
365 {QtTemporalPattern::TemporalFieldFlags}, along with several combinations of
366 those options that select particular commonly-used choices.
367
368 The constants are of type \l {QtTemporalPattern::TemporalFieldFlags}:
369 \list
370
371 \li AnyOffsetForm Enables all offset options.
372 \li BasicDigitOnlyOffset The Qt 'tt' offset format: HH or HHmm, no
373 separator between the hour and minute fields, no UTC or GMT prefix,
374 just the sequence of digits.
375 \li BasicColonDigitOffset The Qt 'ttt' offset format: HH or HH:mm, fields
376 within the offset are separated by colons, there is no UTC or GMT
377 prefix.
378 \li AnyZoneName The Qt 'tttt' format: the IANA ID or localized long name
379 of the zone.
380 \li AllLegacyForm The Qt 't' format: any zone representation supported up
381 to Qt 6.10.
382 \li AnyZoneForm Enables all options.
383
384 \endlist
385*/
386// TODO: this is not, currently, quite true. The colon distinction is a myth.
387
388/*!
389 \internal
390 \since 6.12
391 \class QtParseTimeZone::ParsedZone
392 \brief Describes a text fragment representing a timezone.
393
394 Returned by functions that parse a timezone representation from a text. Its
395 member variables are:
396 \list
397
398 \li zone A timezone representing the result of parsing
399 \li timeType A \l QDateTimePrivate::DaylightStatus indicating the form in
400 which the zone is described by its representation
401 \li startIndex Parsed text offset of the start of the text matched
402 \li endIndex Parsed text offset of the end of the text matched
403
404 \endlist
405
406 The portion of the text that matched stretches from \c startIndex to \c
407 endIndex and can be obtained by passing the same text to \c used(). This
408 shall be empty if \c isEmpty() is \c true.
409
410 The \c zone describes the timezone matched. If \c isEmpty() is \c true, \c
411 zone shall be a lightweight time representation for local time, since a
412 timestamp with no specified zone is conventionally understood to be in local
413 time (although whose local time may be unclear). If this leaves a tail of
414 the text parsed that is otherwise not recognized, it may mean that the text
415 was malformed, or represented a timezone not recognized by the parser. If
416 the portion of the text matched takes a locale-appropriate form for a fixed
417 offset from UTC, \c zone shall be a lightweight time representation for UTC,
418 if the offset is zero, or for the specified offset from UTC. Otherwise, the
419 text matched identified a specific timezone (this only happens if feature \c
420 timezone is enabled) and \c zone is a timezone backed by system data.
421*/
422
423/*!
424 \internal
425 \since 6.12
426 Parses an initial portion of \a text as a timezone, as described by \a locale
427
428 The acceptable forms of a timezone text are controlled by \a flags.
429*/
430QList<ParsedZone> prefix(QStringView text, const QLocale &locale, qsizetype from,
431 QtTemporalPattern::TemporalFieldFlags flags)
432{
433 using QDTP = QDateTimePrivate;
434 QList<ParsedZone> matches;
435 if (from < 0 || from >= text.size())
436 return matches;
437
438 QStringView tail = text.sliced(from);
439 const auto includeMatch = [&matches, from, tail]
440 (qsizetype used, QTimeZone &&zone, QDTP::DaylightStatus type) {
441 Q_ASSERT(zone.isValid());
442 matches = addMatch(std::move(matches), {{from, from + used}, zone, type}, tail);
443 };
444
445 using namespace QtTemporalPattern;
446 using namespace FieldGroup;
447 using Flag = TemporalFieldFlag;
448
449 if (matchesFlagWithin(flags, Flag::Iso8601, FieldGroup::LocalizationMask)) {
450 // Locale-independent offset forms:
451 const auto matches = matchIso8601(tail, flags);
452 for (const auto &match : matches) {
453 includeMatch(match.length,
454 QTimeZone::fromSecondsAheadOfUtc(match.secondsEast),
455 QDTP::UnknownDaylightTime);
456 }
457 }
458
459 // Locale-dependent forms:
460#if QT_CONFIG(timezone)
461 if (matchesFlagWithin(flags, Flag::LocalizedZone, FieldGroup::LocalizationMask)) {
462 const auto addPrefixIfMatch = [includeMatch] (QTimeZonePrivate::NamePrefixMatch &&prefix) {
463 if (prefix) {
464 includeMatch(prefix.nameLength, QTimeZone(prefix.ianaId),
465 timeTypeToStatus(prefix.timeType));
466 }
467 };
468 bool checkOffsetFallbacks = false;
469
470 if (matchesFlagWithin(flags, Flag::Numeric, FormMask)
471 && matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
472 // TODO: have findOffsetPrefix() return a list:
473 addPrefixIfMatch(QTimeZonePrivate::findOffsetPrefix(tail, locale, flags));
474 checkOffsetFallbacks = true; // Might cover some corner cases differently:
475 }
476
477 // IANA after offset-as-such because we prefer offset from UTC
478 // representations over more complex backend representations:
479 if (matchesFlagWithin(flags, Flag::Standalone, FormMask)
480 && matchesFlagWithin(flags, Flag::Short, WidthMask)) {
481 if (auto match = matchIanaId(tail))
482 includeMatch(match.length, std::move(match.zone), QDTP::UnknownDaylightTime);
483 }
484 // ... but before long name, even though that may match some offset forms,
485 // but it only does that as a fall-back, so the IANA choice is better in
486 // that case.
487
488 if (matchesFlagWithin(flags, Flag::Verbal, FormMask)
489 && matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
490 // TODO: findLongNamePrefix() would prefer to be first tried with a date-time.
491 addPrefixIfMatch(QTimeZonePrivate::findLongNamePrefix(tail, locale));
492 // (We don't want offset format to match 'tttt', so do need to limit this.)
493 // The final fall-back for QTZL's localeName() is a
494 // zoneOffsetFormat(,, Numeric | Abbreviated | NeedNoUtcPrefix | ZeroPad ,,):
495 checkOffsetFallbacks = true;
496 }
497
498 if (checkOffsetFallbacks) {
499 addPrefixIfMatch(QTimeZonePrivate::findNarrowOffsetPrefix(tail, locale));
500 addPrefixIfMatch(QTimeZonePrivate::findLongUtcPrefix(tail));
501 }
502 }
503#endif
504
505 if (flags.testFlag(Flag::LocalTimeName)) {
506 if (const auto sys = matchSystemName(tail, locale))
507 includeMatch(sys.length, QTimeZone(QTimeZone::LocalTime), sys.season);
508 }
509 if (text.sliced(from).startsWith(u"LMT")) {
510 // Local (solar) mean time: every zone falls back to this as
511 // abbreviation long enough ago, so we can't resolve it. Treat as local
512 // time, as there's no better way to interpret it.
513 includeMatch(3, QTimeZone(QTimeZone::LocalTime), QDTP::UnknownDaylightTime);
514 }
515
516 return matches;
517}
518
519// ParsedZone find(QStringView text, const QLocale &locale,
520// QtTemporalPattern::TemporalFieldFlags flags, qsizetype from) { }
521} // QtParseTimeZone
522
523QT_END_NAMESPACE
Combined button and popup list for selecting options.
A toolset for parsing time zone identification strings.
QList< ParsedZone > prefix(QStringView text, const QLocale &locale, qsizetype from, QtTemporalPattern::TemporalFieldFlags flags)