master
cpp 485 lines 14.6 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 timestamp.cpp
8
9 Abstract:
10
11 This file contains timestamp and duration helper function definitions.
12
13 --*/
14
15 #include "precomp.h"
16 #include <algorithm>
17 #include <cctype>
18 #include <charconv>
19 #include <chrono>
20 #include <cmath>
21 #include <limits>
22 #include <optional>
23 #include <sstream>
24
25 static std::string LocalUtcOffset()
26 {
27 try
28 {
29 const auto* zone = std::chrono::current_zone();
30 const auto offset = zone->get_info(std::chrono::system_clock::now()).offset;
31 const auto minutes = std::chrono::duration_cast<std::chrono::minutes>(offset).count();
32 const auto magnitude = std::abs(minutes);
33
34 return std::format("{}{:02}:{:02}", minutes < 0 ? '-' : '+', magnitude / 60, magnitude % 60);
35 }
36 catch (...)
37 {
38 // The time zone database is unavailable, so fall back to UTC rather than failing the caller.
39 LOG_CAUGHT_EXCEPTION();
40 return "+00:00";
41 }
42 }
43
44 std::string wsl::windows::common::timestamp::ExpandToRfc3339(const std::string& timestamp)
45 {
46 std::string_view value{timestamp};
47 std::string_view zone;
48
49 if (!value.empty() && (value.back() == 'Z' || value.back() == 'z'))
50 {
51 zone = value.substr(value.size() - 1);
52 value.remove_suffix(1);
53 }
54 else if (const auto plus = value.find('+'); plus != std::string_view::npos)
55 {
56 zone = value.substr(plus);
57 value = value.substr(0, plus);
58 }
59 else if (std::ranges::count(value, '-') == 3)
60 {
61 // The first two dashes belong to the date, so a third one opens a negative zone offset.
62 auto offset = value.find('-');
63 offset = value.find('-', offset + 1);
64 offset = value.find('-', offset + 1);
65
66 zone = value.substr(offset);
67 value = value.substr(0, offset);
68 }
69
70 const auto separator = value.find('T');
71 const auto time = separator == std::string_view::npos ? std::string_view{} : value.substr(separator + 1);
72
73 std::string expanded{value.substr(0, separator)};
74 expanded += 'T';
75
76 if (time.empty())
77 {
78 expanded += "00:00:00";
79 }
80 else
81 {
82 expanded += time;
83
84 // Pad an hour-only or minute-only time out to a full HH:MM:SS.
85 const auto colons = std::ranges::count(time, ':');
86 if (colons == 0)
87 {
88 expanded += ":00:00";
89 }
90 else if (colons == 1)
91 {
92 expanded += ":00";
93 }
94 }
95
96 expanded += zone.empty() ? LocalUtcOffset() : std::string{zone};
97
98 return expanded;
99 }
100
101 std::int64_t wsl::windows::common::timestamp::Rfc3339ToEpoch(const std::string& timestamp)
102 {
103 // Normalize a trailing 'Z' or 'z' to '+00:00' so that %Ez parses every zone uniformly.
104 std::string normalized{timestamp};
105 if (!normalized.empty() && (normalized.back() == 'Z' || normalized.back() == 'z'))
106 {
107 normalized.pop_back();
108 normalized += "+00:00";
109 }
110
111 // Strip any fractional seconds. The value is truncated to whole seconds regardless, and parsing at
112 // second precision keeps timestamps outside the nanosecond range from silently wrapping.
113 const auto separator = normalized.find('.');
114 if (separator != std::string::npos)
115 {
116 auto end = separator + 1;
117 while (end < normalized.size() && std::isdigit(static_cast<unsigned char>(normalized[end])) != 0)
118 {
119 ++end;
120 }
121
122 // A separator with no fractional digits is invalid, but std::chrono::parse otherwise accepts it.
123 THROW_HR_IF_MSG(E_INVALIDARG, end == separator + 1, "Failed to parse timestamp '%hs'", timestamp.c_str());
124
125 normalized.erase(separator, end - separator);
126 }
127
128 // Validate the day up front since std::chrono::parse silently wraps invalid dates (e.g. Feb 31 -> Mar 2).
129 if (normalized.size() >= 10 && normalized[4] == '-' && normalized[7] == '-')
130 {
131 int year{};
132 int month{};
133 int day{};
134 const auto yearResult = std::from_chars(normalized.data(), normalized.data() + 4, year);
135 const auto monthResult = std::from_chars(normalized.data() + 5, normalized.data() + 7, month);
136 const auto dayResult = std::from_chars(normalized.data() + 8, normalized.data() + 10, day);
137 if (yearResult.ec == std::errc() && monthResult.ec == std::errc() && dayResult.ec == std::errc())
138 {
139 const auto date = std::chrono::year{year} / std::chrono::month{static_cast<unsigned>(month)} /
140 std::chrono::day{static_cast<unsigned>(day)};
141
142 THROW_HR_IF_MSG(E_INVALIDARG, !date.ok(), "Failed to parse timestamp '%hs'", timestamp.c_str());
143 }
144 }
145
146 std::chrono::sys_seconds parsed{};
147 std::istringstream stream(normalized);
148 stream >> std::chrono::parse("%FT%T%Ez", parsed);
149 THROW_HR_IF_MSG(E_INVALIDARG, stream.fail(), "Failed to parse timestamp '%hs'", timestamp.c_str());
150 THROW_HR_IF_MSG(
151 E_INVALIDARG,
152 stream.peek() != std::istringstream::traits_type::eof(),
153 "Unexpected trailing characters in timestamp '%hs'",
154 timestamp.c_str());
155
156 return parsed.time_since_epoch().count();
157 }
158
159 std::optional<std::chrono::nanoseconds> wsl::windows::common::timestamp::TryParseDuration(const std::string& duration)
160 {
161 if (duration.empty())
162 {
163 return std::nullopt;
164 }
165
166 size_t pos = 0;
167 bool negative = false;
168 if (duration[pos] == '+' || duration[pos] == '-')
169 {
170 negative = duration[pos] == '-';
171 pos++;
172 }
173
174 // Special case: a bare "0" (with optional sign) is a valid zero duration.
175 if (duration.substr(pos) == "0")
176 {
177 return std::chrono::nanoseconds{0};
178 }
179
180 // Accumulate in a long double so fractional units (e.g. "1.5h") are handled, then round.
181 long double totalNanos = 0.0L;
182 bool sawValue = false;
183
184 while (pos < duration.size())
185 {
186 // Parse the numeric part (integer and/or fraction).
187 const size_t numberStart = pos;
188 while (pos < duration.size() && (std::isdigit(static_cast<unsigned char>(duration[pos])) || duration[pos] == '.'))
189 {
190 pos++;
191 }
192
193 const std::string numberStr = duration.substr(numberStart, pos - numberStart);
194 if (numberStr.empty() || numberStr == "." || std::count(numberStr.begin(), numberStr.end(), '.') > 1)
195 {
196 return std::nullopt;
197 }
198
199 // Parse the unit (everything up to the next digit or '.').
200 const size_t unitStart = pos;
201 while (pos < duration.size() && !std::isdigit(static_cast<unsigned char>(duration[pos])) && duration[pos] != '.')
202 {
203 pos++;
204 }
205
206 const std::string unit = duration.substr(unitStart, pos - unitStart);
207
208 long double multiplier{};
209 if (unit == "ns")
210 {
211 multiplier = 1.0L;
212 }
213 else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */)
214 {
215 multiplier = 1000L;
216 }
217 else if (unit == "ms")
218 {
219 multiplier = 1000000L;
220 }
221 else if (unit == "s")
222 {
223 multiplier = 1000000000L;
224 }
225 else if (unit == "m")
226 {
227 multiplier = 60000000000L;
228 }
229 else if (unit == "h")
230 {
231 multiplier = 3600000000000L;
232 }
233 else
234 {
235 return std::nullopt;
236 }
237
238 long double value{};
239 auto [ptr, ec] = std::from_chars(numberStr.data(), numberStr.data() + numberStr.size(), value, std::chars_format::fixed);
240 if (ptr != numberStr.data() + numberStr.size() || ec != std::errc())
241 {
242 return std::nullopt;
243 }
244
245 totalNanos += value * multiplier;
246 sawValue = true;
247 }
248
249 if (!sawValue)
250 {
251 return std::nullopt;
252 }
253
254 if (negative)
255 {
256 totalNanos = -totalNanos;
257 }
258
259 if (totalNanos > static_cast<long double>(std::numeric_limits<int64_t>::max()) ||
260 totalNanos < static_cast<long double>(std::numeric_limits<int64_t>::min()))
261 {
262 return std::nullopt;
263 }
264
265 return std::chrono::nanoseconds{static_cast<int64_t>(std::llroundl(totalNanos))};
266 }
267
268 std::string wsl::windows::common::timestamp::EpochToLocalDisplayTime(LONGLONG timestamp)
269 {
270 const auto time =
271 std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::from_time_t(static_cast<std::time_t>(timestamp)));
272
273 try
274 {
275 const auto* zone = std::chrono::current_zone();
276 return std::format("{:%F %T %z} {}", std::chrono::zoned_time{zone, time}, zone->get_info(time).abbrev);
277 }
278 catch (...)
279 {
280 // The time zone database is unavailable, so report UTC rather than failing the caller.
281 LOG_CAUGHT_EXCEPTION();
282 return std::format("{:%F %T} +0000 UTC", time);
283 }
284 }
285
286 std::string wsl::windows::common::timestamp::Rfc3339ToUtcDisplayTime(std::string_view timestamp)
287 {
288 if (timestamp.empty())
289 {
290 return {};
291 }
292
293 // Fractional digits are dropped by the parse and vary in length, so they are captured verbatim
294 // and re-inserted after formatting.
295 std::string fraction;
296 const auto separator = timestamp.find('.');
297 if (separator != std::string_view::npos)
298 {
299 auto end = separator + 1;
300 while (end < timestamp.size() && (std::isdigit(static_cast<unsigned char>(timestamp[end])) != 0))
301 {
302 end++;
303 }
304
305 fraction = timestamp.substr(separator, end - separator);
306 }
307
308 const std::chrono::sys_seconds parsed{std::chrono::seconds{Rfc3339ToEpoch(std::string{timestamp})}};
309
310 // Network timestamps are reported in UTC rather than the local time zone.
311 return std::format("{:%F %T}{} +0000 UTC", parsed, fraction);
312 }
313
314 namespace {
315
316 enum class ElapsedUnit
317 {
318 LessThanASecond,
319 OneSecond,
320 Seconds,
321 AboutAMinute,
322 Minutes,
323 AboutAnHour,
324 Hours,
325 Days,
326 Weeks,
327 Months,
328 Years,
329 };
330
331 struct ElapsedDuration
332 {
333 ElapsedUnit Unit;
334 LONGLONG Count;
335 };
336
337 } // namespace
338
339 // Buckets an elapsed duration using the thresholds docker applies in go-units HumanDuration. The
340 // localized and invariant renderings share this so the two can only differ in wording.
341 static ElapsedDuration ClassifyElapsedSeconds(LONGLONG elapsedSeconds)
342 {
343 using namespace std::chrono_literals;
344
345 constexpr LONGLONG SecondsPerMinute = std::chrono::duration_cast<std::chrono::seconds>(1min).count();
346 constexpr LONGLONG SecondsPerHour = std::chrono::duration_cast<std::chrono::seconds>(1h).count();
347 constexpr LONGLONG HoursPerDay = 24;
348 constexpr LONGLONG MinutesPerHour = 60;
349
350 const auto elapsed = std::max<LONGLONG>(elapsedSeconds, 0);
351
352 if (elapsed < 1)
353 {
354 return {ElapsedUnit::LessThanASecond, 0};
355 }
356 else if (elapsed == 1)
357 {
358 return {ElapsedUnit::OneSecond, 1};
359 }
360 else if (elapsed < SecondsPerMinute)
361 {
362 return {ElapsedUnit::Seconds, elapsed};
363 }
364
365 const auto minutes = elapsed / SecondsPerMinute;
366 if (minutes == 1)
367 {
368 return {ElapsedUnit::AboutAMinute, 1};
369 }
370 else if (minutes < MinutesPerHour)
371 {
372 return {ElapsedUnit::Minutes, minutes};
373 }
374
375 // Rounded to the nearest hour rather than truncated.
376 const auto hours = (elapsed + (SecondsPerHour / 2)) / SecondsPerHour;
377 if (hours == 1)
378 {
379 return {ElapsedUnit::AboutAnHour, 1};
380 }
381 else if (hours < HoursPerDay * 2)
382 {
383 return {ElapsedUnit::Hours, hours};
384 }
385 else if (hours < HoursPerDay * 7 * 2)
386 {
387 return {ElapsedUnit::Days, hours / HoursPerDay};
388 }
389 else if (hours < HoursPerDay * 30 * 2)
390 {
391 return {ElapsedUnit::Weeks, hours / HoursPerDay / 7};
392 }
393 else if (hours < HoursPerDay * 365 * 2)
394 {
395 return {ElapsedUnit::Months, hours / HoursPerDay / 30};
396 }
397
398 return {ElapsedUnit::Years, elapsed / SecondsPerHour / HoursPerDay / 365};
399 }
400
401 std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elapsedSeconds)
402 {
403 using wsl::shared::Localization;
404
405 const auto [unit, count] = ClassifyElapsedSeconds(elapsedSeconds);
406 switch (unit)
407 {
408 case ElapsedUnit::LessThanASecond:
409 return Localization::WSLCCLI_RelativeTimeLessThanASecond();
410 case ElapsedUnit::OneSecond:
411 return Localization::WSLCCLI_RelativeTimeOneSecond();
412 case ElapsedUnit::Seconds:
413 return Localization::WSLCCLI_RelativeTimeSeconds(count);
414 case ElapsedUnit::AboutAMinute:
415 return Localization::WSLCCLI_RelativeTimeAboutAMinute();
416 case ElapsedUnit::Minutes:
417 return Localization::WSLCCLI_RelativeTimeMinutes(count);
418 case ElapsedUnit::AboutAnHour:
419 return Localization::WSLCCLI_RelativeTimeAboutAnHour();
420 case ElapsedUnit::Hours:
421 return Localization::WSLCCLI_RelativeTimeHours(count);
422 case ElapsedUnit::Days:
423 return Localization::WSLCCLI_RelativeTimeDays(count);
424 case ElapsedUnit::Weeks:
425 return Localization::WSLCCLI_RelativeTimeWeeks(count);
426 case ElapsedUnit::Months:
427 return Localization::WSLCCLI_RelativeTimeMonths(count);
428 case ElapsedUnit::Years:
429 return Localization::WSLCCLI_RelativeTimeYears(count);
430 default:
431 THROW_HR(E_UNEXPECTED);
432 }
433 }
434
435 std::wstring wsl::windows::common::timestamp::FormatInvariantElapsedSeconds(LONGLONG elapsedSeconds)
436 {
437 const auto [unit, count] = ClassifyElapsedSeconds(elapsedSeconds);
438 switch (unit)
439 {
440 case ElapsedUnit::LessThanASecond:
441 return L"Less than a second ago";
442 case ElapsedUnit::OneSecond:
443 return L"1 second ago";
444 case ElapsedUnit::Seconds:
445 return std::format(L"{} seconds ago", count);
446 case ElapsedUnit::AboutAMinute:
447 return L"About a minute ago";
448 case ElapsedUnit::Minutes:
449 return std::format(L"{} minutes ago", count);
450 case ElapsedUnit::AboutAnHour:
451 return L"About an hour ago";
452 case ElapsedUnit::Hours:
453 return std::format(L"{} hours ago", count);
454 case ElapsedUnit::Days:
455 return std::format(L"{} days ago", count);
456 case ElapsedUnit::Weeks:
457 return std::format(L"{} weeks ago", count);
458 case ElapsedUnit::Months:
459 return std::format(L"{} months ago", count);
460 case ElapsedUnit::Years:
461 return std::format(L"{} years ago", count);
462 default:
463 THROW_HR(E_UNEXPECTED);
464 }
465 }
466
467 std::wstring wsl::windows::common::timestamp::FormatRelativeTime(LONGLONG timestamp)
468 {
469 if (timestamp == 0)
470 {
471 return {};
472 }
473
474 return FormatElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - timestamp);
475 }
476
477 std::wstring wsl::windows::common::timestamp::FormatInvariantRelativeTime(LONGLONG timestamp)
478 {
479 if (timestamp == 0)
480 {
481 return {};
482 }
483
484 return FormatInvariantElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - timestamp);
485 }