#include "nocal/storage/ics_store.hpp" #include #include #include #include #include #include #include #include #include #include #if !defined(_WIN32) #include #endif namespace { using namespace std::chrono; void require(bool condition, const char* message) { if (!condition) { throw std::runtime_error(message); } } void write_fixture(const std::filesystem::path& path, const std::string& contents) { std::ofstream output(path, std::ios::binary); output << contents; require(static_cast(output), "failed to write test fixture"); } std::string read_fixture(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); require(static_cast(input), "failed to open test fixture"); return {std::istreambuf_iterator{input}, {}}; } void require_no_temporary_files(const std::filesystem::path& path) { const std::string prefix = "." + path.filename().string() + ".tmp."; for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) { require(!entry.path().filename().string().starts_with(prefix), "atomic save left a temporary file behind"); } } nocal::Event event_named(std::string uid, std::string title, int day_offset = 0) { nocal::Event event; event.uid = std::move(uid); event.title = std::move(title); event.start = sys_days{year{2026}/July/17} + days{day_offset} + 9h; event.end = event.start + 1h; return event; } void test_round_trip_and_folding(const std::filesystem::path& path) { nocal::Event timed; timed.uid = "meeting-1@example.test"; timed.title = "Planning, review; and \\ decisions"; timed.start = sys_days{year{2026}/July/17} + 9h + 30min + 15s; timed.end = timed.start + 45min; timed.location = "Nomarchy HQ"; timed.description = "First line\nA long UTF-8 line: café café café café café café café café café café café café"; timed.calendar_id = "work"; timed.color = "#89b4fa"; nocal::Event all_day; all_day.uid = "holiday-1@example.test"; all_day.title = "Release day"; all_day.start = sys_days{year{2026}/July/20}; all_day.end = sys_days{year{2026}/July/22}; all_day.all_day = true; const std::vector source{timed, all_day}; const auto saved_revision = nocal::storage::IcsStore::save(path, source); const auto loaded = nocal::storage::IcsStore::load(path); require(saved_revision.existed() && saved_revision.size() > 0, "save did not return an existing non-empty revision"); require(loaded.revision.existed() && loaded.revision.size() == saved_revision.size(), "load did not retain the exact saved revision"); require(loaded.warnings.empty(), "saved calendar produced load warnings"); require(loaded.safe_to_rewrite, "nocal-generated calendar was not safe to rewrite"); require(loaded.events.size() == 2, "round-trip event count differs"); const auto& loaded_timed = loaded.events[0]; require(loaded_timed.uid == timed.uid, "UID did not round-trip"); require(loaded_timed.title == timed.title, "escaped SUMMARY did not round-trip"); require(loaded_timed.start == timed.start && loaded_timed.end == timed.end, "timed interval did not round-trip"); require(loaded_timed.description == timed.description, "DESCRIPTION did not round-trip"); require(loaded_timed.calendar_id == timed.calendar_id && loaded_timed.color == timed.color, "nocal extension properties did not round-trip"); require(loaded.events[1].all_day, "all-day flag did not round-trip"); require(loaded.events[1].start == all_day.start && loaded.events[1].end == all_day.end, "all-day interval did not round-trip"); std::ifstream input(path, std::ios::binary); const std::string bytes{std::istreambuf_iterator{input}, {}}; require(bytes.find("\r\n ") != std::string::npos, "long property was not folded"); require(bytes.find("Planning\\, review\\; and \\\\ decisions") != std::string::npos, "TEXT escaping was not written"); std::size_t offset = 0; while (offset < bytes.size()) { const std::size_t end = bytes.find("\r\n", offset); require(end != std::string::npos, "physical line is not CRLF terminated"); require(end - offset <= 75, "folded physical line exceeds 75 octets"); offset = end + 2; } } void test_local_utc_and_recovery(const std::filesystem::path& path) { write_fixture(path, "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "END:VEVENT\r\n" "BEGIN:VEVENT\r\n" "UID:local\r\n" "DTSTART:20260717T101500\r\n" "DTEND:20260717T111500\r\n" "SUMMARY:Local time\r\n" "END:VEVENT\r\n" "BEGIN:VEVENT\r\n" "UID:utc\r\n" "DTSTART:20260717T101500Z\r\n" "DTEND:20260717T111500Z\r\n" "DESCRIPTION:folded text begins here and continues over a deliberately long value \r\n" " with the rest\r\n" "END:VEVENT\r\n" "BEGIN:VEVENT\r\n" "UID:one-day\r\n" "DTSTART;VALUE=DATE:20260718\r\n" "END:VEVENT\r\n" "BEGIN:VEVENT\r\n" "UID:broken\r\n" "DTSTART:not-a-date\r\n" "END:VEVENT\r\n" "END:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(loaded.events.size() == 3, "malformed VEVENT was not isolated"); require(!loaded.warnings.empty(), "malformed VEVENT was not diagnosed"); require(!loaded.safe_to_rewrite, "malformed source was considered safe to rewrite"); require(loaded.events[0].start == loaded.events[1].start, "local and UTC DATE-TIME should agree while tests run in UTC"); require(loaded.events[2].all_day, "DATE DTSTART was not recognized"); require(loaded.events[2].end - loaded.events[2].start == 24h, "implicit all-day DTEND should be the following day in UTC"); require(loaded.events[1].description.find("value with the rest") != std::string::npos, "folded content line was not unfolded"); } void test_missing_file(const std::filesystem::path& path) { std::error_code ignored; std::filesystem::remove(path, ignored); const auto loaded = nocal::storage::IcsStore::load(path); require(loaded.events.empty() && loaded.warnings.empty(), "missing calendar should load as empty without warnings"); require(loaded.safe_to_rewrite, "missing calendar should be safe to create"); require(!loaded.revision.existed() && loaded.revision.size() == 0, "missing calendar produced an existing revision"); } void test_nested_creation_and_permissions(const std::filesystem::path& root) { const auto path = root / "new" / "nested" / "calendar.ics"; nocal::storage::IcsStore::save(path, {}); require(std::filesystem::is_regular_file(path), "save did not create a nested calendar path"); require(read_fixture(path).starts_with("BEGIN:VCALENDAR\r\n"), "new calendar does not contain a VCALENDAR"); require_no_temporary_files(path); #if !defined(_WIN32) struct stat metadata {}; require(::stat(path.c_str(), &metadata) == 0, "could not stat saved calendar"); require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0, "saved calendar is not readable and writable by its owner"); require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0, "saved calendar grants group or other permissions"); #endif } void test_atomic_replacement(const std::filesystem::path& root) { const auto path = root / "replace.ics"; write_fixture(path, "old calendar bytes"); nocal::Event replacement; replacement.uid = "replacement@example.test"; replacement.title = "Replacement"; replacement.start = sys_days{year{2026}/July/23} + 10h; replacement.end = replacement.start + 30min; const std::vector events{replacement}; nocal::storage::IcsStore::save(path, events); const std::string bytes = read_fixture(path); require(bytes.find("old calendar bytes") == std::string::npos, "save did not replace the existing calendar"); require(bytes.find("UID:replacement@example.test") != std::string::npos, "replacement calendar bytes are incomplete"); require_no_temporary_files(path); } void test_revisions_reject_external_changes(const std::filesystem::path& root) { const auto path = root / "revisions.ics"; const auto backup = nocal::storage::IcsStore::backup_path(path); const std::vector original_events{ event_named("revision-1@example.test", "Original")}; const auto missing = nocal::storage::IcsStore::load(path); const auto created = nocal::storage::IcsStore::save(path, original_events, missing.revision); require(created.existed(), "created revision says the calendar does not exist"); require(!std::filesystem::exists(backup), "initial creation unexpectedly made a backup"); const auto loaded = nocal::storage::IcsStore::load(path); const std::string original = read_fixture(path); std::string externally_changed = original; const auto title = externally_changed.find("SUMMARY:Original"); require(title != std::string::npos, "test fixture lacks the expected title"); externally_changed[title + std::string("SUMMARY:").size()] = 'X'; require(externally_changed.size() == original.size(), "external edit changed fixture size"); write_fixture(path, externally_changed); write_fixture(backup, "existing backup must remain"); bool failed = false; std::string message; try { const std::vector replacement{ event_named("revision-2@example.test", "Replacement")}; (void)nocal::storage::IcsStore::save(path, replacement, loaded.revision); } catch (const std::runtime_error& error) { failed = true; message = error.what(); } require(failed, "same-size external change was not rejected"); require(message.find("reload") != std::string::npos, "external-change error did not tell the caller to reload"); require(read_fixture(path) == externally_changed, "conflicting save changed the externally modified calendar"); require(read_fixture(backup) == "existing backup must remain", "conflicting save changed the existing backup"); require_no_temporary_files(path); } void test_backup_replacement_failure_preserves_calendar(const std::filesystem::path& root) { #if !defined(_WIN32) const auto path = root / "backup-replacement-failure.ics"; const std::vector original_events{ event_named("survivor@example.test", "Must survive")}; const auto revision = nocal::storage::IcsStore::save(path, original_events); const std::string before = read_fixture(path); const auto backup = nocal::storage::IcsStore::backup_path(path); std::filesystem::create_directory(backup); write_fixture(backup / "sentinel", "backup destination must survive"); bool failed = false; try { const std::vector replacement{ event_named("replacement@example.test", "Replacement")}; (void)nocal::storage::IcsStore::save(path, replacement, revision); } catch (const std::runtime_error&) { failed = true; } require(failed, "save unexpectedly replaced a non-empty backup directory"); require(read_fixture(path) == before, "backup failure changed the calendar"); require(read_fixture(backup / "sentinel") == "backup destination must survive", "backup failure damaged the existing backup destination"); require_no_temporary_files(path); require_no_temporary_files(backup); #else (void)root; #endif } void test_backup_rotation_permissions_and_restore(const std::filesystem::path& root) { const auto path = root / "backup.ics"; const auto backup = nocal::storage::IcsStore::backup_path(path); const std::vector first_events{event_named("first@example.test", "First")}; const auto first_revision = nocal::storage::IcsStore::save(path, first_events); const std::string first_bytes = read_fixture(path); require(!std::filesystem::exists(backup), "initial save created a backup"); const std::vector second_events{event_named("second@example.test", "Second", 1)}; const auto second_revision = nocal::storage::IcsStore::save(path, second_events, first_revision); const std::string second_bytes = read_fixture(path); require(read_fixture(backup) == first_bytes, "backup does not contain exact previous bytes"); #if !defined(_WIN32) struct stat metadata {}; require(::stat(backup.c_str(), &metadata) == 0, "could not stat calendar backup"); require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0, "calendar backup grants group or other permissions"); require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0, "calendar backup is not owner-readable and owner-writable"); #endif const std::vector third_events{event_named("third@example.test", "Third", 2)}; const auto third_revision = nocal::storage::IcsStore::save(path, third_events, second_revision); require(read_fixture(backup) == second_bytes, "backup did not rotate to the previous revision"); const std::string backup_before_restore = read_fixture(backup); const auto restored = nocal::storage::IcsStore::restore_backup(path, third_revision); require(restored.existed() && restored.size() == backup_before_restore.size(), "restore returned an incorrect revision"); require(read_fixture(path) == second_bytes, "restore did not install exact backup bytes"); require(read_fixture(backup) == backup_before_restore, "restore changed the backup"); std::string external = read_fixture(path); external[0] = external[0] == 'B' ? 'X' : 'B'; write_fixture(path, external); bool conflict = false; try { (void)nocal::storage::IcsStore::restore_backup(path, restored); } catch (const std::runtime_error&) { conflict = true; } require(conflict, "restore ignored an external calendar change"); require(read_fixture(path) == external, "conflicting restore changed the calendar"); require(read_fixture(backup) == backup_before_restore, "conflicting restore changed the backup"); require_no_temporary_files(path); } void test_restore_requires_backup(const std::filesystem::path& root) { const auto path = root / "missing-backup.ics"; const std::vector events{event_named("only@example.test", "Only")}; const auto revision = nocal::storage::IcsStore::save(path, events); const std::string before = read_fixture(path); bool failed = false; try { (void)nocal::storage::IcsStore::restore_backup(path, revision); } catch (const std::runtime_error&) { failed = true; } require(failed, "restore unexpectedly succeeded without a backup"); require(read_fixture(path) == before, "failed restore changed the calendar"); require(!std::filesystem::exists(nocal::storage::IcsStore::backup_path(path)), "failed restore created a backup"); require_no_temporary_files(path); } void test_lock_failure_preserves_existing_bytes(const std::filesystem::path& root) { #if !defined(_WIN32) const auto path = root / "must-survive.ics"; const std::string original = "existing bytes must survive"; write_fixture(path, original); const auto backup = nocal::storage::IcsStore::backup_path(path); write_fixture(backup, "existing backup must survive"); std::filesystem::path lock_path = path; lock_path += ".lock"; std::filesystem::create_directory(lock_path); bool failed = false; try { nocal::storage::IcsStore::save(path, {}); } catch (const std::runtime_error&) { failed = true; } require(failed, "save unexpectedly succeeded without acquiring its destination lock"); require(read_fixture(path) == original, "failed save modified the existing calendar"); require(read_fixture(backup) == "existing backup must survive", "failed save modified the existing backup"); require_no_temporary_files(path); std::filesystem::remove(lock_path); #else (void)root; #endif } void test_rename_failure_cleans_temporary_file(const std::filesystem::path& root) { #if !defined(_WIN32) const auto path = root / "destination-is-a-directory.ics"; std::filesystem::create_directory(path); const auto sentinel = path / "existing-data"; write_fixture(sentinel, "must remain"); bool failed = false; try { nocal::storage::IcsStore::save(path, {}); } catch (const std::runtime_error&) { failed = true; } require(failed, "save unexpectedly replaced a non-empty directory"); require(read_fixture(sentinel) == "must remain", "failed replacement damaged existing data"); require_no_temporary_files(path); #else (void)root; #endif } void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root) { const auto path = root / "unsupported.ics"; write_fixture(path, "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "BEGIN:VEVENT\r\n" "UID:recurring\r\n" "DTSTART;TZID=Europe/London:20260717T101500\r\n" "DTEND;TZID=Europe/London:20260717T111500\r\n" "SUMMARY;LANGUAGE=en:Recurring appointment\r\n" "RRULE:FREQ=WEEKLY\r\n" "ATTENDEE:mailto:person@example.test\r\n" "BEGIN:VALARM\r\n" "ACTION:DISPLAY\r\n" "TRIGGER:-PT10M\r\n" "END:VALARM\r\n" "END:VEVENT\r\n" "X-UNKNOWN-CALENDAR-PROPERTY:keep me\r\n" "END:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(!loaded.safe_to_rewrite, "unsupported recurrence, alarm, attendee, parameters, or properties were considered safe"); require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled"); } void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) { const auto path = root / "time-bases.ics"; write_fixture(path, "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "BEGIN:VEVENT\r\nUID:floating\r\nSUMMARY:Floating\r\n" "DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:utc-basis\r\nSUMMARY:UTC\r\n" "DTSTART:20260717T101500Z\r\nDTEND:20260717T111500Z\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:london\r\nSUMMARY:London\r\n" "DTSTART;TZID=Europe/London:20260717T101500\r\n" "DTEND;TZID=Europe/London:20260717T111500\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" "EXDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:dst\r\nSUMMARY:DST transition\r\n" "DTSTART;TZID=Europe/London:20260329T003000\r\n" "DTEND;TZID=Europe/London:20260329T023000\r\nEND:VEVENT\r\n" "END:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(loaded.safe_to_rewrite && loaded.warnings.empty(), "supported time bases were not rewrite-safe"); require(loaded.events.size() == 4, "time-basis fixture event count differs"); require(loaded.events[0].time_basis == nocal::TimeBasis::floating, "floating DATE-TIME lost its basis"); require(loaded.events[1].time_basis == nocal::TimeBasis::utc, "UTC DATE-TIME lost its basis"); require(loaded.events[2].time_basis == nocal::TimeBasis::zoned && loaded.events[2].time_zone == "Europe/London", "TZID DATE-TIME lost its zone"); require(loaded.events[2].start == sys_days{year{2026}/July/17} + 9h + 15min, "Europe/London summer offset was not applied"); require(loaded.events[2].recurrence_exceptions == std::vector{ sys_days{year{2026}/July/18} + 9h + 15min, sys_days{year{2026}/July/19} + 9h + 15min}, "zoned EXDATE values were not converted with TZID"); require(loaded.events[3].end - loaded.events[3].start == 1h, "Europe/London DST transition was not converted correctly"); const auto round_trip = root / "time-bases-round-trip.ics"; nocal::storage::IcsStore::save(round_trip, loaded.events); const std::string serialized = read_fixture(round_trip); require(serialized.find("DTSTART:20260717T101500\r\n") != std::string::npos, "floating DTSTART was not serialized without a suffix"); require(serialized.find("DTSTART:20260717T101500Z\r\n") != std::string::npos, "UTC DTSTART was not serialized with Z"); require(serialized.find("DTSTART;TZID=Europe/London:20260717T101500\r\n") != std::string::npos, "zoned DTSTART was not serialized with TZID and local fields"); require(serialized.find( "EXDATE;TZID=Europe/London:20260718T101500,20260719T101500") != std::string::npos, "zoned EXDATE values were not grouped with TZID"); const auto reloaded = nocal::storage::IcsStore::load(round_trip); require(reloaded.safe_to_rewrite && reloaded.events.size() == loaded.events.size(), "time-basis output did not reload safely"); for (std::size_t index = 0; index < loaded.events.size(); ++index) { require(reloaded.events[index].start == loaded.events[index].start && reloaded.events[index].end == loaded.events[index].end && reloaded.events[index].time_basis == loaded.events[index].time_basis && reloaded.events[index].time_zone == loaded.events[index].time_zone && reloaded.events[index].recurrence == loaded.events[index].recurrence && reloaded.events[index].recurrence_additions == loaded.events[index].recurrence_additions && reloaded.events[index].recurrence_exceptions == loaded.events[index].recurrence_exceptions, "time-basis semantic round trip differs"); } } void test_supported_recurrence_and_exdates(const std::filesystem::path& root) { const auto path = root / "recurrence.ics"; write_fixture(path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" "BEGIN:VEVENT\r\nUID:daily\r\nDTSTART:20260701T090000Z\r\n" "DTEND:20260701T100000Z\r\nRRULE:FREQ=DAILY;INTERVAL=2;COUNT=5\r\n" "EXDATE:20260703T090000Z,20260705T090000Z\r\n" "EXDATE:20260707T090000Z\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20260701T090000\r\n" "DTEND:20260701T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;" "UNTIL=20260801T090000\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:monthly\r\nDTSTART:20260701T090000Z\r\n" "DTEND:20260701T100000Z\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1,-1\r\n" "END:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:yearly\r\nDTSTART;VALUE=DATE:20260701\r\n" "DTEND;VALUE=DATE:20260702\r\nRRULE:FREQ=YEARLY;BYMONTH=1,7;" "UNTIL=20290701\r\nEXDATE;VALUE=DATE:20270701,20280701\r\nEND:VEVENT\r\n" "END:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(loaded.safe_to_rewrite && loaded.warnings.empty(), "supported recurrence was not rewrite-safe"); require(loaded.events.size() == 4, "recurrence fixture event count differs"); const auto& daily = *loaded.events[0].recurrence; require(daily.frequency == nocal::RecurrenceFrequency::daily && daily.interval == 2 && daily.count == 5 && !daily.until, "daily RRULE fields differ"); require(loaded.events[0].recurrence_exceptions.size() == 3, "multiple/comma-separated EXDATE values were not collected"); const auto& weekly = *loaded.events[1].recurrence; require(weekly.frequency == nocal::RecurrenceFrequency::weekly && weekly.by_weekday == std::vector{Monday, Wednesday, Friday} && weekly.until.has_value(), "weekly RRULE fields differ"); const auto& monthly = *loaded.events[2].recurrence; require(monthly.frequency == nocal::RecurrenceFrequency::monthly && monthly.by_month_day == std::vector{1, -1}, "monthly RRULE fields differ"); const auto& yearly = *loaded.events[3].recurrence; require(yearly.frequency == nocal::RecurrenceFrequency::yearly && yearly.by_month == std::vector{1, 7} && yearly.until, "yearly RRULE fields differ"); require(loaded.events[3].recurrence_exceptions.size() == 2, "all-day EXDATE values differ"); const auto round_trip = root / "recurrence-round-trip.ics"; nocal::storage::IcsStore::save(round_trip, loaded.events); const std::string serialized = read_fixture(round_trip); require(serialized.find("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5") != std::string::npos, "daily RRULE was not serialized"); require(serialized.find("EXDATE:20260703T090000Z,20260705T090000Z,20260707T090000Z") != std::string::npos, "timed EXDATE values were not grouped"); require(serialized.find("EXDATE;VALUE=DATE:20270701,20280701") != std::string::npos, "all-day EXDATE values were not grouped with VALUE=DATE"); const auto reloaded = nocal::storage::IcsStore::load(round_trip); require(reloaded.safe_to_rewrite && reloaded.warnings.empty(), "serialized recurrence did not reload safely"); require(reloaded.events.size() == loaded.events.size(), "recurrence semantic round-trip event count differs"); for (std::size_t index = 0; index < loaded.events.size(); ++index) { require(reloaded.events[index].recurrence == loaded.events[index].recurrence && reloaded.events[index].recurrence_additions == loaded.events[index].recurrence_additions && reloaded.events[index].recurrence_exceptions == loaded.events[index].recurrence_exceptions, "recurrence semantic round trip differs"); } } void test_supported_rdates(const std::filesystem::path& root) { const auto path = root / "rdates.ics"; write_fixture(path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" "BEGIN:VEVENT\r\nUID:utc-rdate\r\nSUMMARY:UTC additions\r\n" "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" "RDATE:20260718T090000Z,20260720T090000Z\r\n" "RDATE:20260722T090000Z\r\nEXDATE:20260720T090000Z\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:floating-rdate\r\nSUMMARY:Floating additions\r\n" "DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\n" "RDATE:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:zoned-rdate\r\nSUMMARY:Zoned additions\r\n" "DTSTART;TZID=Europe/London:20260717T101500\r\n" "DTEND;TZID=Europe/London:20260717T111500\r\n" "RDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:all-day-rdate\r\nSUMMARY:All-day additions\r\n" "DTSTART;VALUE=DATE:20260717\r\nDTEND;VALUE=DATE:20260718\r\n" "RDATE;VALUE=DATE:20260719,20260721\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:mixed-rdate\r\nSUMMARY:Rule and additions\r\n" "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" "RRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20260725T090000Z,20260727T090000Z\r\n" "EXDATE:20260718T090000Z,20260725T090000Z\r\nEND:VEVENT\r\n" "END:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(loaded.safe_to_rewrite && loaded.warnings.empty(), "supported RDATE values were not rewrite-safe"); require(loaded.events.size() == 5, "RDATE fixture event count differs"); require(!loaded.events[0].recurrence.has_value(), "RDATE-only event acquired an RRULE"); require(loaded.events[0].recurrence_additions == std::vector{ sys_days{year{2026}/July/18} + 9h, sys_days{year{2026}/July/20} + 9h, sys_days{year{2026}/July/22} + 9h}, "multiple or comma-separated UTC RDATE values were not collected"); require(loaded.events[0].recurrence_exceptions == std::vector{sys_days{year{2026}/July/20} + 9h}, "RDATE-only EXDATE value was not retained"); require(loaded.events[1].time_basis == nocal::TimeBasis::floating && loaded.events[1].recurrence_additions == std::vector{ sys_days{year{2026}/July/18} + 10h + 15min, sys_days{year{2026}/July/19} + 10h + 15min}, "floating RDATE values did not preserve their time basis"); require(loaded.events[2].time_basis == nocal::TimeBasis::zoned && loaded.events[2].time_zone == "Europe/London" && loaded.events[2].recurrence_additions == std::vector{ sys_days{year{2026}/July/18} + 9h + 15min, sys_days{year{2026}/July/19} + 9h + 15min}, "zoned RDATE values were not converted with DTSTART's TZID"); require(loaded.events[3].all_day && loaded.events[3].recurrence_additions == std::vector{ sys_days{year{2026}/July/19}, sys_days{year{2026}/July/21}}, "all-day RDATE values were not parsed as DATE values"); require(loaded.events[4].recurrence.has_value() && loaded.events[4].recurrence_additions == std::vector{ sys_days{year{2026}/July/25} + 9h, sys_days{year{2026}/July/27} + 9h} && loaded.events[4].recurrence_exceptions == std::vector{ sys_days{year{2026}/July/18} + 9h, sys_days{year{2026}/July/25} + 9h}, "mixed RRULE, RDATE, and EXDATE values differ"); const auto round_trip = root / "rdates-round-trip.ics"; nocal::storage::IcsStore::save(round_trip, loaded.events); const std::string serialized = read_fixture(round_trip); require(serialized.find( "RDATE:20260718T090000Z,20260720T090000Z,20260722T090000Z") != std::string::npos, "UTC RDATE values were not grouped on serialization"); require(serialized.find("RDATE:20260718T101500,20260719T101500") != std::string::npos, "floating RDATE values were not serialized without a suffix"); require(serialized.find( "RDATE;TZID=Europe/London:20260718T101500,20260719T101500") != std::string::npos, "zoned RDATE values were not serialized with TZID"); require(serialized.find("RDATE;VALUE=DATE:20260719,20260721") != std::string::npos, "all-day RDATE values were not serialized with VALUE=DATE"); const auto reloaded = nocal::storage::IcsStore::load(round_trip); require(reloaded.safe_to_rewrite && reloaded.warnings.empty(), "serialized RDATE calendar did not reload safely"); require(reloaded.events.size() == loaded.events.size(), "RDATE semantic round-trip event count differs"); for (std::size_t index = 0; index < loaded.events.size(); ++index) { require(reloaded.events[index].recurrence == loaded.events[index].recurrence && reloaded.events[index].recurrence_additions == loaded.events[index].recurrence_additions && reloaded.events[index].recurrence_exceptions == loaded.events[index].recurrence_exceptions, "RDATE semantic round trip differs"); } } void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) { const std::vector unsupported_properties{ "RRULE:FREQ=WEEKLY;BYDAY=1MO", "RRULE:FREQ=DAILY;BYDAY=MO", "RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z", "RRULE:FREQ=HOURLY", "RRULE:FREQ=DAILY;WKST=MO", "RDATE;VALUE=PERIOD:20260718T090000Z/PT1H", "RECURRENCE-ID:20260718T090000Z"}; for (std::size_t index = 0; index < unsupported_properties.size(); ++index) { const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics"); write_fixture(path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unsupported\r\n" "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" + unsupported_properties[index] + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(!loaded.safe_to_rewrite && !loaded.warnings.empty(), "unsupported recurrence feature was considered rewrite-safe"); require(loaded.events.size() == 1, "unsupported recurrence prevented base-event browseability"); } const auto multiple = root / "multiple-rrule.ics"; write_fixture(multiple, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:multiple\r\n" "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" "RRULE:FREQ=DAILY\r\nRRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); require(!nocal::storage::IcsStore::load(multiple).safe_to_rewrite, "multiple RRULE properties were considered rewrite-safe"); const auto unknown_zone = root / "unknown-zone.ics"; write_fixture(unknown_zone, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unknown-zone\r\n" "DTSTART;TZID=Custom/Mars:20260717T090000\r\n" "DTEND;TZID=Custom/Mars:20260717T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); const auto unknown = nocal::storage::IcsStore::load(unknown_zone); require(!unknown.safe_to_rewrite && unknown.events.size() == 1, "unknown TZID was not read-only and browseable"); const auto custom_zone = root / "vtimezone.ics"; write_fixture(custom_zone, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Custom/Test\r\n" "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"); struct InvalidRdate { std::string start; std::string end; std::string property; }; const std::vector invalid_rdates{ {"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z", "RDATE:20260718T090000"}, {"DTSTART;TZID=Europe/London:20260717T090000", "DTEND;TZID=Europe/London:20260717T100000", "RDATE;TZID=Europe/Paris:20260718T090000"}, {"DTSTART;VALUE=DATE:20260717", "DTEND;VALUE=DATE:20260718", "RDATE:20260719T090000Z"}, {"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z", "RDATE;TZID=Custom/Mars:20260718T090000"}, {"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z", "RDATE:not-a-date"}}; for (std::size_t index = 0; index < invalid_rdates.size(); ++index) { const auto path = root / ("invalid-rdate-" + std::to_string(index) + ".ics"); const auto& fixture = invalid_rdates[index]; write_fixture(path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:invalid-rdate\r\n" + fixture.start + "\r\n" + fixture.end + "\r\n" + fixture.property + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(!loaded.safe_to_rewrite && !loaded.warnings.empty(), "invalid RDATE was considered rewrite-safe"); require(loaded.events.size() == 1 && loaded.events[0].recurrence_additions.empty(), "invalid RDATE prevented base-event browsing or became an occurrence"); } const std::vector> mismatched_ends{ {"DTSTART:20260717T090000Z", "DTEND:20260717T100000"}, {"DTSTART;TZID=Europe/London:20260717T090000", "DTEND;TZID=Europe/Paris:20260717T100000"}, {"DTSTART;VALUE=DATE:20260717", "DTEND:20260718T100000Z"}}; for (std::size_t index = 0; index < mismatched_ends.size(); ++index) { const auto path = root / ("mismatched-time-" + std::to_string(index) + ".ics"); write_fixture(path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:mismatch\r\n" + mismatched_ends[index].first + "\r\n" + mismatched_ends[index].second + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); const auto loaded = nocal::storage::IcsStore::load(path); require(!loaded.safe_to_rewrite && loaded.events.empty(), "mismatched DTSTART/DTEND kinds were accepted"); } } void test_save_validation_preserves_existing_bytes(const std::filesystem::path& root) { const auto path = root / "validation-preserves.ics"; const std::string original = "existing bytes must survive validation"; const std::string original_backup = "existing backup must survive validation"; write_fixture(path, original); const auto backup = nocal::storage::IcsStore::backup_path(path); write_fixture(backup, original_backup); nocal::Event invalid_zone = event_named("zone@example.test", "Invalid zone"); invalid_zone.time_basis = nocal::TimeBasis::zoned; invalid_zone.time_zone = "Custom/Unknown"; bool zone_failed = false; try { nocal::storage::IcsStore::save(path, std::vector{invalid_zone}); } catch (const std::invalid_argument&) { zone_failed = true; } require(zone_failed && read_fixture(path) == original, "invalid TZID validation changed existing bytes"); require(read_fixture(backup) == original_backup, "invalid TZID validation changed existing backup bytes"); nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule"); nocal::RecurrenceRule rule; rule.frequency = nocal::RecurrenceFrequency::daily; rule.by_weekday.push_back(Monday); invalid_rule.recurrence = rule; bool rule_failed = false; try { nocal::storage::IcsStore::save(path, std::vector{invalid_rule}); } catch (const std::invalid_argument&) { rule_failed = true; } require(rule_failed && read_fixture(path) == original, "invalid RRULE validation changed existing bytes"); require(read_fixture(backup) == original_backup, "invalid RRULE validation changed existing backup bytes"); nocal::Event lone_exception = event_named("exdate@example.test", "Lone exception"); lone_exception.recurrence_exceptions.push_back(lone_exception.start + 24h); bool exdate_failed = false; try { nocal::storage::IcsStore::save(path, std::vector{lone_exception}); } catch (const std::invalid_argument&) { exdate_failed = true; } require(exdate_failed && read_fixture(path) == original && read_fixture(backup) == original_backup, "EXDATE-without-RRULE-or-RDATE validation changed calendar or backup bytes"); nocal::Event duplicate_rdate = event_named("rdate@example.test", "Duplicate RDATE"); duplicate_rdate.recurrence_additions = { duplicate_rdate.start + 24h, duplicate_rdate.start + 24h}; bool rdate_failed = false; try { nocal::storage::IcsStore::save(path, std::vector{duplicate_rdate}); } catch (const std::invalid_argument&) { rdate_failed = true; } require(rdate_failed && read_fixture(path) == original && read_fixture(backup) == original_backup, "duplicate RDATE validation changed calendar or backup bytes"); require_no_temporary_files(path); } } // namespace int main() { #if !defined(_WIN32) setenv("TZ", "UTC", 1); tzset(); #endif const auto root = std::filesystem::temp_directory_path() / "nocal-ics-tests"; const auto path = root / "calendar.ics"; try { std::error_code ignored; std::filesystem::remove_all(root, ignored); std::filesystem::create_directories(root); test_round_trip_and_folding(path); test_local_utc_and_recovery(path); test_missing_file(path); test_nested_creation_and_permissions(root); test_atomic_replacement(root); test_revisions_reject_external_changes(root); test_backup_replacement_failure_preserves_calendar(root); test_backup_rotation_permissions_and_restore(root); test_restore_requires_backup(root); test_lock_failure_preserves_existing_bytes(root); test_rename_failure_cleans_temporary_file(root); test_unsupported_data_is_not_rewrite_safe(root); test_time_basis_round_trips_and_dst(root); test_supported_recurrence_and_exdates(root); test_supported_rdates(root); test_unsupported_recurrence_and_zones_are_unsafe(root); test_save_validation_preserves_existing_bytes(root); std::filesystem::remove_all(root, ignored); std::cout << "ics_tests: ok\n"; return 0; } catch (const std::exception& error) { std::error_code ignored; std::filesystem::remove_all(root, ignored); std::cerr << "ics_tests: " << error.what() << '\n'; return 1; } }