Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
// Qt-Security score:critical reason:data-parser
#include "private/qtparsetimezone_p.h"
#include "qdatetime.h"
#include "qlocale.h"
#include "private/qlocale_p.h"
#include <QtCore/qloggingcategory.h>
#include "qstring.h"
#include "private/qtenvironmentvariables_p.h" // for tzName()
#include "qtimezone.h"
#if QT_CONFIG(timezone)
# include "private/qtimezoneprivate_p.h"
#endif
QT_BEGIN_NAMESPACE
using namespace Qt::StringLiterals;
namespace {
QList<QtParseTimeZone::ParsedZone>
addMatch(QList<QtParseTimeZone::ParsedZone> &&matches,
QtParseTimeZone::ParsedZone &&match, [[maybe_unused]] QStringView tail)
{
// Input matches is sorted with x before y when isBetter(x, y); add our new
// entry just after the last that isBetter(than, match).
using namespace QtParseTimeZone;
// How discerning isBetter() can be depends on whether zones can have backends.
const auto isBetter = [
#if QT_CONFIG(timezone)
tail,
#endif
newAddr = &match] (const ParsedZone &left, const ParsedZone &right) {
Q_ASSERT(left.startIndex == right.startIndex);
if (left.endIndex > right.endIndex)
return true;
if (left.endIndex < right.endIndex)
return false;
// That leaves matches of equal length, which may have interpreted the
// same thing in different ways, most likely equivalent. We do have some
// preferences among these, though.
const auto preferTruer = [](bool goodL, bool goodR) -> std::optional<bool> {
if (goodL != goodR)
return goodL;
return {};
};
const Qt::TimeSpec leftSpec = left.zone.timeSpec(), rightSpec = right.zone.timeSpec();
// UTC and fixed-offset time representations are particularly nice:
if (const auto pref = preferTruer(QTimeZone::isUtcOrFixedOffset(leftSpec),
QTimeZone::isUtcOrFixedOffset(rightSpec))) {
return *pref;
}
// For historical reasons (e.g. QTBUG-114575) we prefer local time over
// the local zone's representation as a zone:
if (const auto pref = preferTruer(leftSpec == Qt::LocalTime, rightSpec == Qt::LocalTime))
return *pref;
#if QT_CONFIG(timezone)
// A zone whose ID is exactly the text matched is also clearly better:
if (const auto pref = preferTruer(leftSpec == Qt::TimeZone
&& tail.startsWith(QLatin1String(left.zone.id())),
rightSpec == Qt::TimeZone
&& tail.startsWith(QLatin1String(right.zone.id())))) {
return *pref;
}
#endif
// All other things being equal, prefer entries already in the list over match:
return &right == newAddr;
};
const auto pos = std::upper_bound(matches.begin(), matches.end(), match, isBetter);
// Could condition the following on match not being a duplicate of pos[-1],
// for pos != begin(), but hopefully we simply aren't sending duplicates
// this way, anyway.
matches.insert(pos, match);
return std::move(matches);
}
#if QT_CONFIG(timezone)
constexpr char zoneNamePunctuation[] = "+-./:_";
QDateTimePrivate::DaylightStatus timeTypeToStatus(QTimeZone::TimeType type) {
using QDTP = QDateTimePrivate;
switch (type) {
case QTimeZone::GenericTime: return QDTP::UnknownDaylightTime;
case QTimeZone::StandardTime: return QDTP::StandardTime;
case QTimeZone::DaylightTime: return QDTP::DaylightTime;
}
Q_UNREACHABLE_RETURN(QDTP::UnknownDaylightTime);
}
auto matchIanaId(QStringView text)
{
struct R {
QTimeZone zone;
qsizetype length = 0;
operator bool() const noexcept { return length > 0; }
};
// Collect up plausibly-valid characters; let QTimeZone work out what's
// truly valid.
const auto invalidZoneNameCharacter = [] (const QChar &c) {
static constexpr auto matcher = QtPrivate::makeCharacterSetMatch<zoneNamePunctuation>();
const auto cu = c.unicode();
return cu >= 127u || !(matcher.matches(uchar(cu)) || c.isLetterOrNumber());
};
qsizetype index = std::distance(text.cbegin(), std::find_if(text.cbegin(), text.cend(),
invalidZoneNameCharacter));
if (!index)
return R{};
Q_ASSERT(index <= text.size());
text.truncate(index);
// Limit name fragments (between slashes) to 20 characters.
// (Valid time-zone IDs are allowed up to 14 and Android has quirks up to 17.)
constexpr qsizetype MaxFragmentLength = 20;
// Limit number of fragments to six; no known zone name has more than four.
constexpr int MaxFragmentIndex = 5;
qsizetype lastSlash = -1;
int fragment = 1;
while (lastSlash < index) {
const qsizetype newToken = lastSlash + 1;
qsizetype slash = text.indexOf(u'/', newToken);
if (slash < 0)
slash = index; // i.e. the end of the candidate text
else if (++fragment > MaxFragmentIndex)
index = slash; // Truncate
if (slash - newToken > MaxFragmentLength)
index = newToken + MaxFragmentLength; // Truncate
// If any of those conditions was met, index <= slash, so this exits the loop:
lastSlash = slash;
}
// Only ASCII characters are valid, so we can now convert to Latin1.
QByteArray name = text.first(index).toLatin1();
// Subsequent truncation won't trigger reallocation, so is efficient despite
// the owning container.
// IANA includes a limited few three-letter abbreviations as IDs.
// Find longest IANA ID match:
for (; index >= 3; name.truncate(--index)) {
QTimeZone zone(name);
if (zone.isValid())
return R{zone, index};
}
// Not a known IANA ID.
return R{};
}
#endif // feature timezone
auto matchSystemName(QStringView text, const QLocale &locale)
{
using QDTP = QDateTimePrivate;
struct R {
qsizetype length = 0;
QDTP::DaylightStatus season = QDTP::UnknownDaylightTime;
operator bool() const noexcept { return length > 0; }
} best;
qTzSet();
// On MS-Win, at least when system zone is UTC, qTzName() can return empty.
for (int i = 0; i < 2; ++i) {
const QString zone(qTzName(i));
if (zone.size() > best.length && text.startsWith(zone))
best = { zone.size(), i ? QDTP::DaylightTime : QDTP::StandardTime };
}
#if QT_CONFIG(timezone)
// Mimic each candidate QLocale::toString() could have used, to ensure round-trips work:
const auto consider = [text, &best](QStringView zone, QDTP::DaylightStatus season) {
if (text.startsWith(zone)) {
// UTC-based zone's displayName() only includes minutes if non-zero:
constexpr qsizetype utcSignHourWidth = 6, withMinutesWidth = 9;
if (withMinutesWidth > best.length && zone.size() == utcSignHourWidth
&& zone.startsWith("UTC"_L1)
&& text.sliced(utcSignHourWidth).startsWith(":00"_L1)) {
best = { withMinutesWidth, QDTP::UnknownDaylightTime };
} else if (zone.size() > best.length) {
best = { zone.size(), season };
}
}
};
/* QLocale::toString would skip this if locale == QLocale::system(), but we
might not be using the same system locale as whoever generated the text
we're parsing. So consider it anyway. */
if (const QTimeZone sys = QTimeZone::systemTimeZone(); sys.hasDaylightTime()) {
constexpr QTimeZone::TimeType types[] = {
QTimeZone::GenericTime, QTimeZone::StandardTime, QTimeZone::DaylightTime };
for (const auto timeType : types) {
consider(sys.displayName(timeType, QTimeZone::ShortName, locale),
timeTypeToStatus(timeType));
}
} else {
consider(sys.displayName(QTimeZone::GenericTime, QTimeZone::ShortName, locale),
QDTP::UnknownDaylightTime);
}
#else
Q_UNUSED(locale);
#endif
return best;
}
struct SizeOffset {
qsizetype length = 0;
int secondsEast = 0;
constexpr SizeOffset(qsizetype size, int offset) : length(size), secondsEast(offset) {}
};
// Locale-independent ISO 8601 offset forms
QList<SizeOffset> matchIso8601(QStringView text, QtTemporalPattern::TemporalFieldFlags flags)
{
constexpr int MaxOffsetHours
= (std::max)(-QTimeZone::MinUtcOffsetSecs, QTimeZone::MaxUtcOffsetSecs) / 3600;
QList<SizeOffset> matches;
using namespace QtTemporalPattern;
using namespace FieldGroup;
using Flag = TemporalFieldFlag;
if (flags.testFlag(Flag::AllowZSuffix) && text.startsWith(QLatin1Char('Z'))) {
matches.emplace_back(1, 0);
// No other ISO 8601 offset form starts with Z.
return matches;
}
qsizetype used = 0;
QStringView tail = text; // Invariant: is a prefix of text.sliced(used)
if (tail.startsWith(u"UTC")) {
if (!matchesFlagWithin(flags, Flag::AcceptUtcPrefix, UtcPrefixMask))
return matches;
used += 3;
tail = tail.sliced(3);
} else if (!matchesFlagWithin(flags, Flag::NeedNoUtcPrefix, UtcPrefixMask)) {
return matches;
}
const bool negate = tail.startsWith(u'-');
if (!negate && !tail.startsWith(u'+')) {
if (used) // Starts with "UTC", which is a match:
matches.emplace_back(used, 0);
return matches;
}
++used;
tail = tail.sliced(1);
if (tail.isEmpty())
return matches;
const auto extend = [&matches, negate](qsizetype length, int secondsEast) {
if (negate)
secondsEast = -secondsEast;
if (secondsEast >= QTimeZone::MinUtcOffsetSecs
&& secondsEast <= QTimeZone::MaxUtcOffsetSecs) {
matches.emplace_back(length, secondsEast);
}
};
int hours = 0, minutes = 0, seconds = 0;
const bool zeroPad = flags.testFlag(Flag::ZeroPad);
constexpr TemporalFieldFlags WithColon = Flag::Verbal | Flag::Standalone;
qsizetype colon = tail.indexOf(u':');
if (colon == 0) // No digits in (first field of) offset.
return matches;
if (!matchesFlagsWithin(flags, WithColon, FormMask)) { // Colon forbidden.
if (colon > 0) {
// Treat as juxtaposed fields with cruft starting at the colon:
tail = tail.first(colon);
colon = -1;
}
} else if (!matchesFlagWithin(flags, Flag::Numeric, FormMask)) { // Colon required
if (colon > 2) {
// Too long for a single field. Treat as hour field followed by trailing
// cruft, since our colon is too late to separate it from a later field.
tail = tail.first(2);
colon = -1; // There is no longer a colon in tail.
} else if (colon < 0) {
// Lack of expected colon - we have, at most, an hour field:
if (tail.size() > 2)
tail = tail.first(2);
}
} // else: if a colon is there, read fields up to it.
// If we have a colon at the end of the hour field, each field must end in a
// colon. No field is wider than two digits, so a colon further out than
// that isn't the end of the hour field, just part of some dangling cruft.
const bool hasColon = colon > 0 && colon <= 2;
bool ok;
qsizetype fieldUsed = qMin(2, hasColon ? colon : tail.size());
hours = tail.first(fieldUsed).toInt(&ok);
if (!ok || hours > MaxOffsetHours || (zeroPad && fieldUsed < 2)) {
if (zeroPad) // Hour field must have full width.
return matches;
hours = tail.first(1).toInt(&ok);
fieldUsed = 1;
// Single-digit hour is only allowed in colon-separated form; if we
// don't have an actual colon, the parse must end after this field.
if (!ok)
return matches;
}
tail = tail.sliced(fieldUsed);
used += fieldUsed;
qsizetype fieldEnd[3] = { used, 0, 0 };
int fieldsSeen = 1; // Seen hour field
// If we're allowed more than just hour, see what we've got:
if ((flags & WidthMask) != QtTemporalPattern::TemporalFieldFlags{Flag::Narrow}) {
for (int i = 0; i < 2 && fieldUsed && !tail.isEmpty(); ++i) {
QStringView digits = tail;
qsizetype sepLen = 0;
if (hasColon || fieldUsed == 1) {
if (fieldUsed != colon)
break;
Q_ASSERT(tail.startsWith(u':'));
digits = digits.sliced(1);
sepLen = 1;
}
int &field = i ? seconds : minutes;
colon = hasColon && !i ? digits.indexOf(u':') : -1;
if (colon == 0) // Empty field
break;
if ((colon == -1 ? digits.size() : colon) < 2) // Not enough digits for field.
break;
field = digits.first(2).toInt(&ok);
if (!ok || field >= 60)
break;
fieldUsed = 2; // So next iteration sees that to compare to colon.
tail = tail.sliced(sepLen + fieldUsed);
used += sepLen + fieldUsed;
fieldEnd[fieldsSeen++] = used;
// Quit loop after 1st iteration unless accepting seconds field:
if (!i && !matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask))
break;
}
}
// Check we got enough fields, add entries, with longer matches earlier:
switch (fieldsSeen) {
case 3: // Would have exited loop early unless:
Q_ASSERT(matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask));
// TODO: if Wide, check for fractional part.
extend(fieldEnd[--fieldsSeen], (hours * 60 + minutes) * 60 + seconds);
Q_FALLTHROUGH();
case 2: // Hour and minute supplied.
if (!zeroPad || matchesFlagWithin(flags, Flag::Abbreviated, WidthMask))
extend(fieldEnd[fieldsSeen - 1], (hours * 60 + minutes) * 60);
--fieldsSeen;
Q_FALLTHROUGH();
case 1: // Only hour supplied: need Narrow if ZeroPad:
if (zeroPad && !matchesFlagWithin(flags, Flag::Narrow, WidthMask))
break;
extend(fieldEnd[--fieldsSeen], hours * 60 * 60);
}
return matches;
}
}
namespace QtParseTimeZone {
/*!
\internal
\since 6.12
\namespace QtParseTimeZone
\brief A toolset for parsing time zone identification strings
A time zone may be identified by an offset from UTC or, in various ways, by
a name. This namespace provides a \l {QtParseTimeZone::}{prefix()} function
to parse an initial portion of a string as such an identifier, controlled by
configuration options provided by \l
{QtTemporalPattern::TemporalFieldFlags}, along with several combinations of
those options that select particular commonly-used choices.
The constants are of type \l {QtTemporalPattern::TemporalFieldFlags}:
\list
\li AnyOffsetForm Enables all offset options.
\li BasicDigitOnlyOffset The Qt 'tt' offset format: HH or HHmm, no
separator between the hour and minute fields, no UTC or GMT prefix,
just the sequence of digits.
\li BasicColonDigitOffset The Qt 'ttt' offset format: HH or HH:mm, fields
within the offset are separated by colons, there is no UTC or GMT
prefix.
\li AnyZoneName The Qt 'tttt' format: the IANA ID or localized long name
of the zone.
\li AllLegacyForm The Qt 't' format: any zone representation supported up
to Qt 6.10.
\li AnyZoneForm Enables all options.
\endlist
*/
// TODO: this is not, currently, quite true. The colon distinction is a myth.
/*!
\internal
\since 6.12
\class QtParseTimeZone::ParsedZone
\brief Describes a text fragment representing a timezone.
Returned by functions that parse a timezone representation from a text. Its
member variables are:
\list
\li zone A timezone representing the result of parsing
\li timeType A \l QDateTimePrivate::DaylightStatus indicating the form in
which the zone is described by its representation
\li startIndex Parsed text offset of the start of the text matched
\li endIndex Parsed text offset of the end of the text matched
\endlist
The portion of the text that matched stretches from \c startIndex to \c
endIndex and can be obtained by passing the same text to \c used(). This
shall be empty if \c isEmpty() is \c true.
The \c zone describes the timezone matched. If \c isEmpty() is \c true, \c
zone shall be a lightweight time representation for local time, since a
timestamp with no specified zone is conventionally understood to be in local
time (although whose local time may be unclear). If this leaves a tail of
the text parsed that is otherwise not recognized, it may mean that the text
was malformed, or represented a timezone not recognized by the parser. If
the portion of the text matched takes a locale-appropriate form for a fixed
offset from UTC, \c zone shall be a lightweight time representation for UTC,
if the offset is zero, or for the specified offset from UTC. Otherwise, the
text matched identified a specific timezone (this only happens if feature \c
timezone is enabled) and \c zone is a timezone backed by system data.
*/
/*!
\internal
\since 6.12
Parses an initial portion of \a text as a timezone, as described by \a locale
The acceptable forms of a timezone text are controlled by \a flags.
*/
QList<ParsedZone> prefix(QStringView text, const QLocale &locale, qsizetype from,
QtTemporalPattern::TemporalFieldFlags flags)
{
using QDTP = QDateTimePrivate;
QList<ParsedZone> matches;
if (from < 0 || from >= text.size())
return matches;
QStringView tail = text.sliced(from);
const auto includeMatch = [&matches, from, tail]
(qsizetype used, QTimeZone &&zone, QDTP::DaylightStatus type) {
Q_ASSERT(zone.isValid());
matches = addMatch(std::move(matches), {{from, from + used}, zone, type}, tail);
};
using namespace QtTemporalPattern;
using namespace FieldGroup;
using Flag = TemporalFieldFlag;
if (matchesFlagWithin(flags, Flag::Iso8601, FieldGroup::LocalizationMask)) {
// Locale-independent offset forms:
const auto matches = matchIso8601(tail, flags);
for (const auto &match : matches) {
includeMatch(match.length,
QTimeZone::fromSecondsAheadOfUtc(match.secondsEast),
QDTP::UnknownDaylightTime);
}
}
// Locale-dependent forms:
#if QT_CONFIG(timezone)
if (matchesFlagWithin(flags, Flag::LocalizedZone, FieldGroup::LocalizationMask)) {
const auto addPrefixIfMatch = [includeMatch] (QTimeZonePrivate::NamePrefixMatch &&prefix) {
if (prefix) {
includeMatch(prefix.nameLength,
prefix.offset
? QTimeZone::fromSecondsAheadOfUtc(*prefix.offset)
: QTimeZone(prefix.ianaId),
timeTypeToStatus(prefix.timeType));
}
};
bool checkOffsetFallbacks = false;
if (matchesFlagWithin(flags, Flag::Numeric, FormMask)
&& matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
// TODO: have findOffsetPrefix() return a list:
addPrefixIfMatch(QTimeZonePrivate::findOffsetPrefix(tail, locale, flags));
checkOffsetFallbacks = true; // Might cover some corner cases differently:
}
// IANA after offset-as-such because we prefer offset from UTC
// representations over more complex backend representations:
if (matchesFlagWithin(flags, Flag::Standalone, FormMask)
&& matchesFlagWithin(flags, Flag::Short, WidthMask)) {
if (auto match = matchIanaId(tail))
includeMatch(match.length, std::move(match.zone), QDTP::UnknownDaylightTime);
}
// ... but before long name, even though that may match some offset forms,
// but it only does that as a fall-back, so the IANA choice is better in
// that case.
if (matchesFlagWithin(flags, Flag::Verbal, FormMask)
&& matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
// TODO: findLongNamePrefix() would prefer to be first tried with a date-time.
addPrefixIfMatch(QTimeZonePrivate::findLongNamePrefix(tail, locale));
// (We don't want offset format to match 'tttt', so do need to limit this.)
// The final fall-back for QTZL's localeName() is a
// zoneOffsetFormat(,, Numeric | Abbreviated | NeedNoUtcPrefix | ZeroPad ,,):
checkOffsetFallbacks = true;
}
if (checkOffsetFallbacks) {
addPrefixIfMatch(QTimeZonePrivate::findNarrowOffsetPrefix(tail, locale));
addPrefixIfMatch(QTimeZonePrivate::findLongUtcPrefix(tail));
}
}
#endif
if (flags.testFlag(Flag::LocalTimeName)) {
if (const auto sys = matchSystemName(tail, locale))
includeMatch(sys.length, QTimeZone(QTimeZone::LocalTime), sys.season);
}
if (text.sliced(from).startsWith(u"LMT")) {
// Local (solar) mean time: every zone falls back to this as
// abbreviation long enough ago, so we can't resolve it. Treat as local
// time, as there's no better way to interpret it.
includeMatch(3, QTimeZone(QTimeZone::LocalTime), QDTP::UnknownDaylightTime);
}
return matches;
}
// ParsedZone find(QStringView text, const QLocale &locale,
// QtTemporalPattern::TemporalFieldFlags flags, qsizetype from) { }
} // QtParseTimeZone
QT_END_NAMESPACE