feat: embedded VTIMEZONE parsing, round-trip serialization
This commit is contained in:
@@ -27,6 +27,15 @@ private:
|
||||
friend class IcsStore;
|
||||
};
|
||||
|
||||
// Embedded VTIMEZONE definition preserved for round-trip serialization.
|
||||
struct VTimezoneDefinition {
|
||||
std::string tzid;
|
||||
// Complete raw content between BEGIN:VTIMEZONE and END:VTIMEZONE (exclusive
|
||||
// of the framing lines). Empty when the definition was not parsed from a
|
||||
// source calendar.
|
||||
std::string raw;
|
||||
};
|
||||
|
||||
struct LoadResult {
|
||||
std::vector<Event> events;
|
||||
std::vector<std::string> warnings;
|
||||
@@ -36,6 +45,9 @@ struct LoadResult {
|
||||
// Exact bytes that produced this result. Copies share immutable storage and
|
||||
// can be supplied to save/restore as an optimistic concurrency token.
|
||||
FileRevision revision;
|
||||
// Embedded VTIMEZONE definitions from the source calendar, preserved for
|
||||
// round-trip serialization. Populated only by load().
|
||||
std::vector<VTimezoneDefinition> vtimezones;
|
||||
};
|
||||
|
||||
class IcsStore {
|
||||
@@ -49,9 +61,11 @@ public:
|
||||
// requests durable filesystem flushes. I/O failures before replacement are
|
||||
// reported as std::runtime_error. If expected is present, the destination
|
||||
// must still contain the exact bytes represented by that revision.
|
||||
// Supply vtimezones to preserve embedded timezone definitions.
|
||||
static FileRevision save(
|
||||
const std::filesystem::path& path, std::span<const Event> events,
|
||||
std::optional<FileRevision> expected = std::nullopt);
|
||||
std::optional<FileRevision> expected = std::nullopt,
|
||||
std::span<const VTimezoneDefinition> vtimezones = {});
|
||||
|
||||
// Restores the exact bytes in <path>.bak without changing the backup.
|
||||
static FileRevision restore_backup(
|
||||
|
||||
@@ -55,6 +55,16 @@ struct PendingEvent {
|
||||
std::vector<PendingProperty> exception_dates;
|
||||
};
|
||||
|
||||
// Accumulated VTIMEZONE definitions from the source calendar (populated during load).
|
||||
std::vector<VTimezoneDefinition> parsed_vtimezones;
|
||||
bool inside_vtimezone = false;
|
||||
bool vtimezone_has_errors = false;
|
||||
|
||||
[[nodiscard]] bool vtimezone_defined(std::string_view tzid) noexcept {
|
||||
return std::any_of(parsed_vtimezones.begin(), parsed_vtimezones.end(),
|
||||
[&tzid](const VTimezoneDefinition& tz) { return tz.tzid == tzid; });
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string upper(std::string_view value) {
|
||||
std::string result(value);
|
||||
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
|
||||
@@ -358,6 +368,10 @@ void mark_unsafe_to_rewrite(LoadResult& result) {
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool is_unknown_tzid_warning(std::string_view warning) noexcept {
|
||||
return warning.find("unknown TZID=") != std::string::npos;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<std::string_view> split(
|
||||
std::string_view value, char delimiter) {
|
||||
std::vector<std::string_view> parts;
|
||||
@@ -586,17 +600,22 @@ template <typename Integer>
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) {
|
||||
const time_zone* zone = locate_zone(zone_name);
|
||||
const local_seconds local = zone->to_local(floor<seconds>(value));
|
||||
const local_days date = floor<days>(local);
|
||||
const year_month_day ymd{date};
|
||||
const hh_mm_ss time{local - date};
|
||||
return four_digits(static_cast<int>(ymd.year()))
|
||||
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.month())))
|
||||
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T"
|
||||
+ two_digits(static_cast<int>(time.hours().count()))
|
||||
+ two_digits(static_cast<int>(time.minutes().count()))
|
||||
+ two_digits(static_cast<int>(time.seconds().count()));
|
||||
try {
|
||||
const time_zone* zone = locate_zone(zone_name);
|
||||
const local_seconds local = zone->to_local(floor<seconds>(value));
|
||||
const local_days date = floor<days>(local);
|
||||
const year_month_day ymd{date};
|
||||
const hh_mm_ss time{local - date};
|
||||
return four_digits(static_cast<int>(ymd.year()))
|
||||
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.month())))
|
||||
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T"
|
||||
+ two_digits(static_cast<int>(time.hours().count()))
|
||||
+ two_digits(static_cast<int>(time.minutes().count()))
|
||||
+ two_digits(static_cast<int>(time.seconds().count()));
|
||||
} catch (const std::runtime_error&) {
|
||||
// Custom TZID not known to the system; fall back to local formatting.
|
||||
return format_floating_time(value);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string format_event_time(const Event& event, TimePoint value) {
|
||||
@@ -704,11 +723,25 @@ void write_text_property(std::ostream& output, std::string_view name, std::strin
|
||||
}
|
||||
}
|
||||
|
||||
void serialize_calendar(std::ostream& output, std::span<const Event> events) {
|
||||
void serialize_calendar(std::ostream& output, std::span<const Event> events,
|
||||
std::span<const VTimezoneDefinition> vtimezones) {
|
||||
write_folded(output, "BEGIN:VCALENDAR");
|
||||
write_folded(output, "VERSION:2.0");
|
||||
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
|
||||
write_folded(output, "CALSCALE:GREGORIAN");
|
||||
// Preserve embedded VTIMEZONE definitions for round-trip safety.
|
||||
for (const auto& vt : vtimezones) {
|
||||
if (vt.tzid.empty()) continue;
|
||||
write_folded(output, "BEGIN:VTIMEZONE");
|
||||
write_folded(output, "TZID:" + vt.tzid);
|
||||
if (!vt.raw.empty()) {
|
||||
std::istringstream raw_stream(vt.raw);
|
||||
for (const auto& line : unfold(raw_stream)) {
|
||||
write_folded(output, line);
|
||||
}
|
||||
}
|
||||
write_folded(output, "END:VTIMEZONE");
|
||||
}
|
||||
for (const Event& event : events) {
|
||||
write_folded(output, "BEGIN:VEVENT");
|
||||
write_folded(output, "UID:" + escape_text(event.uid));
|
||||
@@ -1058,7 +1091,8 @@ void validate_recurrence(const Event& event) {
|
||||
}
|
||||
}
|
||||
|
||||
void validate_event_for_save(const Event& event) {
|
||||
void validate_event_for_save(
|
||||
const Event& event, std::span<const VTimezoneDefinition> vtimezones) {
|
||||
if (event.uid.empty()) {
|
||||
throw std::invalid_argument("cannot save an event with an empty UID");
|
||||
}
|
||||
@@ -1103,9 +1137,13 @@ void validate_event_for_save(const Event& event) {
|
||||
try {
|
||||
(void)locate_zone(event.time_zone);
|
||||
} catch (const std::runtime_error&) {
|
||||
throw std::invalid_argument(
|
||||
"cannot save zoned event '" + event.uid + "' with unknown TZID '"
|
||||
+ event.time_zone + "'");
|
||||
bool defined_by_vtimezone = std::any_of(vtimezones.begin(), vtimezones.end(),
|
||||
[&event](const VTimezoneDefinition& tz) { return tz.tzid == event.time_zone; });
|
||||
if (!defined_by_vtimezone) {
|
||||
throw std::invalid_argument(
|
||||
"cannot save zoned event '" + event.uid + "' with unknown TZID '"
|
||||
+ event.time_zone + "'");
|
||||
}
|
||||
}
|
||||
} else if (!event.time_zone.empty()) {
|
||||
throw std::invalid_argument(
|
||||
@@ -1135,6 +1173,10 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
}
|
||||
result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes);
|
||||
|
||||
parsed_vtimezones.clear();
|
||||
inside_vtimezone = false;
|
||||
vtimezone_has_errors = false;
|
||||
|
||||
std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
|
||||
const auto lines = unfold(input);
|
||||
std::optional<PendingEvent> pending;
|
||||
@@ -1252,8 +1294,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
continue;
|
||||
}
|
||||
if (!warning.empty()) {
|
||||
const bool is_tzid = is_unknown_tzid_warning(warning);
|
||||
add_warning(result, property.line, std::move(warning));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
if (!is_tzid) {
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
}
|
||||
if (std::find(event.recurrence_additions.begin(),
|
||||
event.recurrence_additions.end(), parsed->value)
|
||||
@@ -1275,8 +1320,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
continue;
|
||||
}
|
||||
if (!warning.empty()) {
|
||||
const bool is_tzid = is_unknown_tzid_warning(warning);
|
||||
add_warning(result, property.line, std::move(warning));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
if (!is_tzid) {
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
}
|
||||
if (std::find(event.recurrence_exceptions.begin(),
|
||||
event.recurrence_exceptions.end(), parsed->value)
|
||||
@@ -1298,6 +1346,39 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
}
|
||||
|
||||
if (!pending) {
|
||||
// Handle embedded VTIMEZONE for safety analysis and round-trip.
|
||||
if (name == "BEGIN" && upper(value) == "VTIMEZONE") {
|
||||
if (inside_vtimezone) {
|
||||
add_warning(result, index + 1, "nested VTIMEZONE is not supported");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else {
|
||||
inside_vtimezone = true;
|
||||
vtimezone_has_errors = false;
|
||||
parsed_vtimezones.emplace_back();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (name == "END" && upper(value) == "VTIMEZONE") {
|
||||
if (!inside_vtimezone) {
|
||||
add_warning(result, index + 1, "END:VTIMEZONE without BEGIN:VTIMEZONE");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else {
|
||||
inside_vtimezone = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inside_vtimezone) {
|
||||
auto& vt = parsed_vtimezones.back();
|
||||
if (!vt.raw.empty()) vt.raw += "\r\n";
|
||||
vt.raw += std::string(line);
|
||||
if (name == "TZID" && vt.tzid.empty()) {
|
||||
vt.tzid = std::string(value);
|
||||
}
|
||||
// Sub-components and their properties are tracked but not deeply
|
||||
// parsed; unsupported content inside VTIMEZONE is deferred.
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string normalized_value = upper(value);
|
||||
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|
||||
|| (name == "END" && normalized_value == "VCALENDAR");
|
||||
@@ -1366,8 +1447,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else {
|
||||
if (!warning.empty()) {
|
||||
const bool is_tzid = is_unknown_tzid_warning(warning);
|
||||
add_warning(result, index + 1, std::move(warning));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
if (!is_tzid) {
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
}
|
||||
destination = *parsed;
|
||||
}
|
||||
@@ -1378,6 +1462,59 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
add_warning(result, event_start_line, "unterminated VEVENT was skipped");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
if (inside_vtimezone) {
|
||||
add_warning(result, 0, "unterminated VTIMEZONE; timezone rules may be incomplete");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
|
||||
// VTIMEZONE-defined TZIDs that are unknown to the system are safe to edit
|
||||
// (we can round-trip the definition) but DST handling is approximate.
|
||||
for (const auto& event : result.events) {
|
||||
if (event.time_basis == TimeBasis::zoned && !event.time_zone.empty()) {
|
||||
const std::string& tzid = event.time_zone;
|
||||
try {
|
||||
(void)locate_zone(tzid);
|
||||
} catch (const std::runtime_error&) {
|
||||
if (vtimezone_defined(tzid)) {
|
||||
add_warning(result, 0,
|
||||
"TZID '" + tzid + "' is defined by VTIMEZONE but not by the system; "
|
||||
"DST transitions use local time as an approximation");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove "unknown TZID" warnings only for TZIDs defined in VTIMEZONE.
|
||||
auto safe_end = std::remove_if(result.warnings.begin(), result.warnings.end(),
|
||||
[](const std::string& w) {
|
||||
const auto marker = w.find("unknown TZID=");
|
||||
if (marker == std::string::npos) return false;
|
||||
// Extract TZID from warning like "line N: unknown TZID='Custom/Test'; ..."
|
||||
const std::size_t start = w.find('\'', marker);
|
||||
const std::size_t end = w.find('\'', start + 1);
|
||||
if (start == std::string::npos || end == std::string::npos) return false;
|
||||
const std::string tzid = w.substr(start + 1, end - start - 1);
|
||||
return vtimezone_defined(tzid);
|
||||
});
|
||||
result.warnings.erase(safe_end, result.warnings.end());
|
||||
|
||||
// If no undefined-TZID warnings remain and only VTIMEZONE-DST warnings
|
||||
// (safe to edit) are present, mark the calendar as safe to rewrite.
|
||||
bool has_undefined_tzid = std::any_of(result.warnings.begin(), result.warnings.end(),
|
||||
[](const std::string& w) { return w.find("unknown TZID=") != std::string::npos; });
|
||||
if (has_undefined_tzid) {
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else if (!result.warnings.empty()) {
|
||||
bool has_unsafe_warning = std::any_of(result.warnings.begin(), result.warnings.end(),
|
||||
[](const std::string& w) {
|
||||
return w.find("VTIMEZONE") == std::string::npos || w.find("DST") == std::string::npos;
|
||||
});
|
||||
if (!has_unsafe_warning) {
|
||||
result.safe_to_rewrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
result.vtimezones = std::move(parsed_vtimezones);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1389,16 +1526,17 @@ std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
|
||||
|
||||
FileRevision IcsStore::save(
|
||||
const std::filesystem::path& path, std::span<const Event> events,
|
||||
std::optional<FileRevision> expected) {
|
||||
std::optional<FileRevision> expected,
|
||||
std::span<const VTimezoneDefinition> vtimezones) {
|
||||
if (path.empty() || path.filename().empty()) {
|
||||
throw std::invalid_argument("calendar path must name a file");
|
||||
}
|
||||
for (const Event& event : events) {
|
||||
validate_event_for_save(event);
|
||||
validate_event_for_save(event, vtimezones);
|
||||
}
|
||||
|
||||
std::ostringstream serialized(std::ios::out | std::ios::binary);
|
||||
serialize_calendar(serialized, events);
|
||||
serialize_calendar(serialized, events, vtimezones);
|
||||
if (!serialized) {
|
||||
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
|
||||
}
|
||||
|
||||
@@ -697,8 +697,14 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
|
||||
"END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n"
|
||||
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite,
|
||||
"VTIMEZONE definition was considered rewrite-safe");
|
||||
// VTIMEZONE is now parsed and preserved for round-trip, so the calendar
|
||||
// is safe to rewrite even though the custom TZID is unused by the event.
|
||||
const auto vtz_result = nocal::storage::IcsStore::load(custom_zone);
|
||||
require(vtz_result.safe_to_rewrite,
|
||||
"VTIMEZONE definition should be safe when round-trippable");
|
||||
require(vtz_result.vtimezones.size() == 1 &&
|
||||
vtz_result.vtimezones[0].tzid == "Custom/Test",
|
||||
"VTIMEZONE should be captured even when no event references it");
|
||||
|
||||
struct InvalidRdate {
|
||||
std::string start;
|
||||
@@ -813,6 +819,131 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
|
||||
require_no_temporary_files(path);
|
||||
}
|
||||
|
||||
void test_vtimezone_custom_tzid_safe_and_round_trips(const std::filesystem::path& root) {
|
||||
const auto path = root / "vtimezone-custom.ics";
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"BEGIN:VTIMEZONE\r\n"
|
||||
"TZID:Custom/Example\r\n"
|
||||
"BEGIN:STANDARD\r\n"
|
||||
"TZOFFSETFROM:+0100\r\n"
|
||||
"TZOFFSETTO:+0000\r\n"
|
||||
"DTSTART:19701025T030000\r\n"
|
||||
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n"
|
||||
"END:STANDARD\r\n"
|
||||
"BEGIN:DAYLIGHT\r\n"
|
||||
"TZOFFSETFROM:+0000\r\n"
|
||||
"TZOFFSETTO:+0100\r\n"
|
||||
"DTSTART:19700329T020000\r\n"
|
||||
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n"
|
||||
"END:DAYLIGHT\r\n"
|
||||
"END:VTIMEZONE\r\n"
|
||||
"BEGIN:VEVENT\r\n"
|
||||
"UID:custom-tz\r\n"
|
||||
"DTSTART;TZID=Custom/Example:20260717T100000\r\n"
|
||||
"DTEND;TZID=Custom/Example:20260717T110000\r\n"
|
||||
"SUMMARY:Custom zone event\r\n"
|
||||
"END:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n");
|
||||
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(loaded.safe_to_rewrite,
|
||||
"VTIMEZONE-defined TZID should make the calendar safe to rewrite");
|
||||
require(loaded.events.size() == 1, "VTIMEZONE calendar should contain the event");
|
||||
require(loaded.events[0].time_zone == "Custom/Example",
|
||||
"VTIMEZONE-defined TZID should be retained on the event");
|
||||
require(loaded.events[0].time_basis == nocal::TimeBasis::zoned,
|
||||
"zoned time basis should be preserved");
|
||||
require(loaded.vtimezones.size() == 1,
|
||||
"VTIMEZONE definition should be captured");
|
||||
require(loaded.vtimezones[0].tzid == "Custom/Example",
|
||||
"VTIMEZONE TZID should match");
|
||||
require(!loaded.vtimezones[0].raw.empty(),
|
||||
"VTIMEZONE raw content should be captured");
|
||||
|
||||
// Verify a VTIMEZONE-DST warning was emitted for unknown TZID.
|
||||
bool has_dst_warning = false;
|
||||
for (const auto& w : loaded.warnings) {
|
||||
if (w.find("DST") != std::string::npos) {
|
||||
has_dst_warning = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
require(has_dst_warning,
|
||||
"should warn that DST handling is approximate for custom VTIMEZONE");
|
||||
|
||||
// Round-trip: save with VTIMEZONE preserved, then reload.
|
||||
const auto saved_revision = nocal::storage::IcsStore::save(
|
||||
path, loaded.events, loaded.revision, loaded.vtimezones);
|
||||
require(saved_revision.existed(), "save should return an existing revision");
|
||||
|
||||
const std::string bytes = read_fixture(path);
|
||||
require(bytes.find("BEGIN:VTIMEZONE") != std::string::npos,
|
||||
"VTIMEZONE block should be serialized on save");
|
||||
require(bytes.find("TZID:Custom/Example") != std::string::npos,
|
||||
"VTIMEZONE TZID should appear in serialized output");
|
||||
require(bytes.find("BEGIN:STANDARD") != std::string::npos,
|
||||
"VTIMEZONE sub-components should be preserved");
|
||||
|
||||
const auto reloaded = nocal::storage::IcsStore::load(path);
|
||||
require(reloaded.safe_to_rewrite, "round-tripped VTIMEZONE calendar should be safe");
|
||||
require(reloaded.events.size() == 1, "round-trip event count should match");
|
||||
require(reloaded.events[0].time_zone == "Custom/Example",
|
||||
"round-trip TZID should be preserved");
|
||||
require(reloaded.vtimezones.size() == 1,
|
||||
"round-trip VTIMEZONE count should match");
|
||||
require(reloaded.vtimezones[0].tzid == "Custom/Example",
|
||||
"round-trip VTIMEZONE TZID should match");
|
||||
require_no_temporary_files(path);
|
||||
}
|
||||
|
||||
void test_vtimezone_multiple_tzids(const std::filesystem::path& root) {
|
||||
const auto path = root / "vtimezone-multiple.ics";
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"BEGIN:VTIMEZONE\r\nTZID:Custom/A\r\nEND:VTIMEZONE\r\n"
|
||||
"BEGIN:VTIMEZONE\r\nTZID:Custom/B\r\nEND:VTIMEZONE\r\n"
|
||||
"BEGIN:VEVENT\r\n"
|
||||
"UID:evt-a\r\n"
|
||||
"DTSTART;TZID=Custom/A:20260717T100000\r\n"
|
||||
"DTEND;TZID=Custom/A:20260717T110000\r\n"
|
||||
"SUMMARY:Event A\r\n"
|
||||
"END:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\n"
|
||||
"UID:evt-b\r\n"
|
||||
"DTSTART;TZID=Custom/B:20260718T100000\r\n"
|
||||
"DTEND;TZID=Custom/B:20260718T110000\r\n"
|
||||
"SUMMARY:Event B\r\n"
|
||||
"END:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n");
|
||||
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(loaded.safe_to_rewrite,
|
||||
"multiple VTIMEZONE-defined TZIDs should be safe");
|
||||
require(loaded.events.size() == 2, "should parse all events");
|
||||
require(loaded.vtimezones.size() == 2, "should capture both VTIMEZONE definitions");
|
||||
}
|
||||
|
||||
void test_vtimezone_undefined_tzid_still_unsafe(const std::filesystem::path& root) {
|
||||
const auto path = root / "vtimezone-undefined.ics";
|
||||
// Event uses Custom/Unknown which is NOT defined in any VTIMEZONE.
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
|
||||
"BEGIN:VTIMEZONE\r\nTZID:Custom/Defined\r\nEND:VTIMEZONE\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:undef\r\n"
|
||||
"DTSTART;TZID=Custom/Unknown:20260717T100000\r\n"
|
||||
"DTEND;TZID=Custom/Unknown:20260717T110000\r\n"
|
||||
"SUMMARY:Undefined TZID\r\nEND:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n");
|
||||
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(!loaded.safe_to_rewrite,
|
||||
"undefined TZID should still be unsafe even when other VTIMEZONEs exist");
|
||||
require(loaded.events.size() == 1, "event should still be browseable");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
@@ -843,6 +974,9 @@ int main() {
|
||||
test_supported_rdates(root);
|
||||
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
||||
test_save_validation_preserves_existing_bytes(root);
|
||||
test_vtimezone_custom_tzid_safe_and_round_trips(root);
|
||||
test_vtimezone_multiple_tzids(root);
|
||||
test_vtimezone_undefined_tzid_still_unsafe(root);
|
||||
std::filesystem::remove_all(root, ignored);
|
||||
std::cout << "ics_tests: ok\n";
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user