#include "nocal/storage/ics_store.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #if !defined(_WIN32) #include #include #include #include #include #include #endif namespace nocal::storage { namespace { using namespace std::chrono; struct ParsedTime { TimePoint value; bool is_date = false; std::optional date; TimeBasis basis = TimeBasis::floating; std::string time_zone; }; struct PendingProperty { std::string parameters; std::string value; std::size_t line = 0; }; struct PendingEvent { std::optional uid; std::string title; std::string location; std::string description; std::string calendar_id; std::string color; std::optional start; std::optional end; std::optional recurrence_id; std::vector recurrence_rules; std::vector recurrence_dates; std::vector exception_dates; }; // Accumulated VTIMEZONE definitions from the source calendar (populated during load). std::vector 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) { return static_cast(std::toupper(ch)); }); return result; } [[nodiscard]] std::string unescape_text(std::string_view value) { std::string result; result.reserve(value.size()); for (std::size_t i = 0; i < value.size(); ++i) { if (value[i] != '\\' || i + 1 == value.size()) { result.push_back(value[i]); continue; } const char escaped = value[++i]; if (escaped == 'n' || escaped == 'N') { result.push_back('\n'); } else { // RFC 5545 TEXT escapes '\\', ';', and ','. Keeping the escaped // character is the least surprising recovery for unknown escapes. result.push_back(escaped); } } return result; } [[nodiscard]] std::string escape_text(std::string_view value) { std::string result; result.reserve(value.size()); for (std::size_t i = 0; i < value.size(); ++i) { switch (value[i]) { case '\\': result += "\\\\"; break; case ';': result += "\\;"; break; case ',': result += "\\,"; break; case '\r': if (i + 1 < value.size() && value[i + 1] == '\n') { ++i; } result += "\\n"; break; case '\n': result += "\\n"; break; default: result.push_back(value[i]); break; } } return result; } [[nodiscard]] bool parse_fixed_int( std::string_view value, std::size_t offset, std::size_t count, int& destination) { if (offset + count > value.size()) { return false; } const char* first = value.data() + offset; const char* last = first + count; if (!std::all_of(first, last, [](unsigned char ch) { return std::isdigit(ch); })) { return false; } const auto [ptr, ec] = std::from_chars(first, last, destination); return ec == std::errc{} && ptr == last; } [[nodiscard]] std::optional parse_ymd(std::string_view value) { if (value.size() != 8) { return std::nullopt; } int year_value = 0; int month_value = 0; int day_value = 0; if (!parse_fixed_int(value, 0, 4, year_value) || !parse_fixed_int(value, 4, 2, month_value) || !parse_fixed_int(value, 6, 2, day_value)) { return std::nullopt; } const year_month_day date{ year{year_value}, month{static_cast(month_value)}, day{static_cast(day_value)}}; return date.ok() ? std::optional{date} : std::nullopt; } [[nodiscard]] std::optional make_local( year_month_day date, int hour_value, int minute_value, int second_value) { std::tm local{}; local.tm_year = static_cast(date.year()) - 1900; local.tm_mon = static_cast(static_cast(date.month())) - 1; local.tm_mday = static_cast(static_cast(date.day())); local.tm_hour = hour_value; local.tm_min = minute_value; local.tm_sec = second_value; local.tm_isdst = -1; const std::time_t converted = std::mktime(&local); // mktime may normalize invalid or nonexistent local times. Accept timezone // normalization only when the requested civil fields survive unchanged. if (local.tm_year != static_cast(date.year()) - 1900 || local.tm_mon != static_cast(static_cast(date.month())) - 1 || local.tm_mday != static_cast(static_cast(date.day())) || local.tm_hour != hour_value || local.tm_min != minute_value || local.tm_sec != second_value) { return std::nullopt; } return system_clock::from_time_t(converted); } struct TimeParameters { bool valid = true; bool value_is_date = false; bool has_value_parameter = false; std::optional time_zone; std::string error; }; [[nodiscard]] TimeParameters parse_time_parameters(std::string_view parameters) { TimeParameters result; std::size_t start = 0; while (start < parameters.size()) { const std::size_t end = parameters.find(';', start); const std::string_view parameter = parameters.substr( start, end == std::string_view::npos ? std::string_view::npos : end - start); const std::size_t equals = parameter.find('='); const std::string name = upper(parameter.substr(0, equals)); std::string_view value = equals == std::string_view::npos ? std::string_view{} : parameter.substr(equals + 1); if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { value.remove_prefix(1); value.remove_suffix(1); } if (name == "VALUE" && !result.has_value_parameter && (upper(value) == "DATE" || upper(value) == "DATE-TIME")) { result.has_value_parameter = true; result.value_is_date = upper(value) == "DATE"; } else if (name == "TZID" && !value.empty() && !result.time_zone) { result.time_zone = std::string(value); } else { result.valid = false; result.error = "unsupported or duplicate DATE-TIME parameter '" + std::string(parameter) + "'"; return result; } if (end == std::string_view::npos) { break; } start = end + 1; } if (result.value_is_date && result.time_zone) { result.valid = false; result.error = "VALUE=DATE cannot have a TZID"; } return result; } [[nodiscard]] std::optional make_zoned( year_month_day date, int hour_value, int minute_value, int second_value, std::string_view zone_name, std::string& warning) { try { const time_zone* zone = locate_zone(zone_name); const local_seconds local = local_days{date} + hours{hour_value} + minutes{minute_value} + seconds{second_value}; const local_info info = zone->get_info(local); if (info.result == local_info::unique) { return zone->to_sys(local); } // RFC 5545 resolves a repeated wall time to its first occurrence and a // nonexistent wall time with the UTC offset in force before the gap. // local_info::first describes exactly that side of both transitions. return sys_seconds{local.time_since_epoch() - info.first.offset}; } catch (const std::runtime_error&) { warning = "unknown TZID='" + std::string(zone_name) + "'; DATE-TIME was interpreted in the process local timezone"; return std::nullopt; } } [[nodiscard]] std::optional parse_time( std::string_view parameters, std::string_view value, std::string& warning) { const TimeParameters parsed_parameters = parse_time_parameters(parameters); if (!parsed_parameters.valid) { warning = parsed_parameters.error; return std::nullopt; } if (parsed_parameters.value_is_date || (parameters.empty() && value.size() == 8)) { const auto date = parse_ymd(value); if (!date) { warning = "invalid DATE value '" + std::string(value) + "'"; return std::nullopt; } const auto local = make_local(*date, 0, 0, 0); if (!local) { warning = "DATE does not map to a local midnight: '" + std::string(value) + "'"; return std::nullopt; } return ParsedTime{*local, true, *date, TimeBasis::floating, {}}; } const bool utc = !value.empty() && value.back() == 'Z'; if (utc && parsed_parameters.time_zone) { warning = "UTC DATE-TIME cannot also have a TZID"; return std::nullopt; } const std::size_t expected_size = utc ? 16 : 15; if (value.size() != expected_size || value[8] != 'T') { warning = "invalid DATE-TIME value '" + std::string(value) + "'"; return std::nullopt; } const auto date = parse_ymd(value.substr(0, 8)); int hour_value = 0; int minute_value = 0; int second_value = 0; if (!date || !parse_fixed_int(value, 9, 2, hour_value) || !parse_fixed_int(value, 11, 2, minute_value) || !parse_fixed_int(value, 13, 2, second_value) || hour_value > 23 || minute_value > 59 || second_value > 60) { warning = "invalid DATE-TIME value '" + std::string(value) + "'"; return std::nullopt; } // POSIX time has no representation for a leap second. Normalize it to the // first instant of the next minute. const int represented_second = std::min(second_value, 59); TimePoint result; TimeBasis basis = TimeBasis::floating; std::string zone_name; if (utc) { result = sys_days{*date} + hours{hour_value} + minutes{minute_value} + seconds{represented_second}; basis = TimeBasis::utc; } else if (parsed_parameters.time_zone) { zone_name = *parsed_parameters.time_zone; std::string zoned_warning; const auto zoned = make_zoned( *date, hour_value, minute_value, represented_second, zone_name, zoned_warning); if (zoned) { result = *zoned; } else if (zoned_warning.starts_with("unknown TZID=")) { warning = std::move(zoned_warning); const auto local = make_local(*date, hour_value, minute_value, represented_second); if (!local) { return std::nullopt; } result = *local; } else { warning = std::move(zoned_warning); return std::nullopt; } basis = TimeBasis::zoned; } else { const auto local = make_local(*date, hour_value, minute_value, represented_second); if (!local) { warning = "DATE-TIME does not exist in the local timezone: '" + std::string(value) + "'"; return std::nullopt; } result = *local; } if (second_value == 60) { result += seconds{1}; } return ParsedTime{result, false, std::nullopt, basis, std::move(zone_name)}; } [[nodiscard]] std::vector unfold(std::istream& input) { std::vector lines; std::string line; while (std::getline(input, line)) { if (!line.empty() && line.back() == '\r') { line.pop_back(); } if (!line.empty() && (line.front() == ' ' || line.front() == '\t')) { if (!lines.empty()) { lines.back().append(line.begin() + 1, line.end()); } } else { lines.push_back(std::move(line)); } } return lines; } [[nodiscard]] std::size_t content_separator(std::string_view line) { bool quoted = false; for (std::size_t i = 0; i < line.size(); ++i) { if (line[i] == '"') { quoted = !quoted; } else if (line[i] == ':' && !quoted) { return i; } } return std::string_view::npos; } void add_warning(LoadResult& result, std::size_t line, std::string message) { result.warnings.push_back( "line " + std::to_string(line) + ": " + std::move(message)); } void mark_unsafe_to_rewrite(LoadResult& result) { if (result.safe_to_rewrite) { result.safe_to_rewrite = false; result.warnings.emplace_back( "calendar contains data nocal cannot preserve; editing is disabled to prevent data loss"); } } [[nodiscard]] bool is_unknown_tzid_warning(std::string_view warning) noexcept { return warning.find("unknown TZID=") != std::string::npos; } [[nodiscard]] std::vector split( std::string_view value, char delimiter) { std::vector parts; std::size_t start = 0; while (start <= value.size()) { const std::size_t end = value.find(delimiter, start); parts.push_back(value.substr( start, end == std::string_view::npos ? std::string_view::npos : end - start)); if (end == std::string_view::npos) { break; } start = end + 1; } return parts; } template [[nodiscard]] bool parse_integer(std::string_view value, Integer& destination) { if (value.empty()) { return false; } const auto [end, error] = std::from_chars( value.data(), value.data() + value.size(), destination); return error == std::errc{} && end == value.data() + value.size(); } [[nodiscard]] std::optional parse_weekday(std::string_view value) { if (value == "SU") return Sunday; if (value == "MO") return Monday; if (value == "TU") return Tuesday; if (value == "WE") return Wednesday; if (value == "TH") return Thursday; if (value == "FR") return Friday; if (value == "SA") return Saturday; return std::nullopt; } [[nodiscard]] std::string weekday_name(weekday value) { static constexpr std::string_view names[]{"SU", "MO", "TU", "WE", "TH", "FR", "SA"}; return std::string(names[value.c_encoding()]); } [[nodiscard]] bool same_time_kind(const ParsedTime& first, const ParsedTime& second) { return first.is_date == second.is_date && (first.is_date || (first.basis == second.basis && first.time_zone == second.time_zone)); } [[nodiscard]] std::optional parse_recurrence_rule( std::string_view value, const ParsedTime& start, std::string& error) { RecurrenceRule rule; bool has_frequency = false; bool has_interval = false; bool has_count = false; bool has_until = false; bool has_by_weekday = false; bool has_by_month_day = false; bool has_by_month = false; for (const std::string_view raw_part : split(value, ';')) { const std::size_t equals = raw_part.find('='); if (equals == std::string_view::npos || equals == 0 || equals + 1 >= raw_part.size()) { error = "malformed RRULE part '" + std::string(raw_part) + "'"; return std::nullopt; } const std::string name = upper(raw_part.substr(0, equals)); const std::string part_value = upper(raw_part.substr(equals + 1)); if (name == "FREQ" && !has_frequency) { has_frequency = true; if (part_value == "DAILY") rule.frequency = RecurrenceFrequency::daily; else if (part_value == "WEEKLY") rule.frequency = RecurrenceFrequency::weekly; else if (part_value == "MONTHLY") rule.frequency = RecurrenceFrequency::monthly; else if (part_value == "YEARLY") rule.frequency = RecurrenceFrequency::yearly; else { error = "unsupported RRULE frequency '" + part_value + "'"; return std::nullopt; } } else if (name == "INTERVAL" && !has_interval) { has_interval = true; if (!parse_integer(part_value, rule.interval) || rule.interval == 0) { error = "RRULE INTERVAL must be a positive integer"; return std::nullopt; } } else if (name == "COUNT" && !has_count) { has_count = true; unsigned count = 0; if (!parse_integer(part_value, count) || count == 0) { error = "RRULE COUNT must be a positive integer"; return std::nullopt; } rule.count = count; } else if (name == "UNTIL" && !has_until) { has_until = true; std::string until_warning; const std::string_view until_parameters = start.is_date ? "VALUE=DATE" : ""; auto parsed = parse_time(until_parameters, part_value, until_warning); if (!parsed || !until_warning.empty()) { error = "invalid RRULE UNTIL: " + (until_warning.empty() ? std::string(part_value) : until_warning); return std::nullopt; } if (start.is_date) { if (!parsed->is_date) { error = "all-day RRULE UNTIL must be a DATE"; return std::nullopt; } } else if (start.basis == TimeBasis::floating) { if (parsed->is_date || parsed->basis != TimeBasis::floating) { error = "floating RRULE UNTIL must be a floating DATE-TIME"; return std::nullopt; } } else if (parsed->is_date || parsed->basis != TimeBasis::utc) { error = "UTC and zoned RRULE UNTIL must be a UTC DATE-TIME"; return std::nullopt; } rule.until = parsed->value; } else if (name == "BYDAY" && !has_by_weekday) { has_by_weekday = true; for (const std::string_view day_name : split(part_value, ',')) { int ordinal = 0; std::string_view weekday_part = day_name; // Check for ordinal prefix: optional sign, optional digits if (!day_name.empty() && (day_name[0] == '+' || day_name[0] == '-' || (day_name[0] >= '0' && day_name[0] <= '9'))) { std::size_t digit_end = 0; if (day_name[0] == '+' || day_name[0] == '-') { digit_end = 1; } while (digit_end < day_name.size() && day_name[digit_end] >= '0' && day_name[digit_end] <= '9') { ++digit_end; } if (digit_end > 0U && digit_end < day_name.size()) { std::string number_str(day_name.substr(0, digit_end)); ordinal = std::stoi(number_str); if (ordinal < -5 || ordinal > 5 || ordinal == 0) { error = "RRULE BYDAY ordinal must be from -5 to -1 or 1 to 5"; return std::nullopt; } weekday_part = day_name.substr(digit_end); } } const auto day = parse_weekday(weekday_part); if (!day) { error = "RRULE BYDAY contains an invalid weekday"; return std::nullopt; } rule.by_weekday.push_back({ordinal, *day}); } } else if (name == "BYMONTHDAY" && !has_by_month_day) { has_by_month_day = true; for (const std::string_view number : split(part_value, ',')) { int day_number = 0; if (!parse_integer(number, day_number) || day_number == 0 || day_number < -31 || day_number > 31) { error = "RRULE BYMONTHDAY must contain integers from -31 to -1 or 1 to 31"; return std::nullopt; } rule.by_month_day.push_back(day_number); } } else if (name == "BYMONTH" && !has_by_month) { has_by_month = true; for (const std::string_view number : split(part_value, ',')) { unsigned month_number = 0; if (!parse_integer(number, month_number) || month_number == 0 || month_number > 12) { error = "RRULE BYMONTH must contain month numbers from 1 to 12"; return std::nullopt; } rule.by_month.push_back(month_number); } } else { error = "unsupported or duplicate RRULE part '" + name + "'"; return std::nullopt; } } if (!has_frequency) { error = "RRULE requires FREQ"; return std::nullopt; } if (has_count && has_until) { error = "RRULE COUNT and UNTIL are mutually exclusive"; return std::nullopt; } return rule; } [[nodiscard]] std::tm utc_tm(std::time_t time) { std::tm result{}; #if defined(_WIN32) gmtime_s(&result, &time); #else gmtime_r(&time, &result); #endif return result; } [[nodiscard]] std::tm local_tm(std::time_t time) { std::tm result{}; #if defined(_WIN32) localtime_s(&result, &time); #else localtime_r(&time, &result); #endif return result; } [[nodiscard]] std::string two_digits(int value) { std::string result(2, '0'); result[0] = static_cast('0' + (value / 10) % 10); result[1] = static_cast('0' + value % 10); return result; } [[nodiscard]] std::string four_digits(int value) { std::string result(4, '0'); for (int index = 3; index >= 0; --index) { result[static_cast(index)] = static_cast('0' + value % 10); value /= 10; } return result; } [[nodiscard]] std::string format_date(TimePoint value) { const std::tm local = local_tm(system_clock::to_time_t(value)); return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1) + two_digits(local.tm_mday); } [[nodiscard]] std::string format_utc_time(TimePoint value) { const auto whole_seconds = floor(value); const std::tm utc = utc_tm(system_clock::to_time_t(whole_seconds)); return four_digits(utc.tm_year + 1900) + two_digits(utc.tm_mon + 1) + two_digits(utc.tm_mday) + "T" + two_digits(utc.tm_hour) + two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z"; } [[nodiscard]] std::string format_floating_time(TimePoint value) { const auto whole_seconds = floor(value); const std::tm local = local_tm(system_clock::to_time_t(whole_seconds)); return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1) + two_digits(local.tm_mday) + "T" + two_digits(local.tm_hour) + two_digits(local.tm_min) + two_digits(local.tm_sec); } [[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) { try { const time_zone* zone = locate_zone(zone_name); const local_seconds local = zone->to_local(floor(value)); const local_days date = floor(local); const year_month_day ymd{date}; const hh_mm_ss time{local - date}; return four_digits(static_cast(ymd.year())) + two_digits(static_cast(static_cast(ymd.month()))) + two_digits(static_cast(static_cast(ymd.day()))) + "T" + two_digits(static_cast(time.hours().count())) + two_digits(static_cast(time.minutes().count())) + two_digits(static_cast(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) { switch (event.time_basis) { case TimeBasis::floating: return format_floating_time(value); case TimeBasis::utc: return format_utc_time(value); case TimeBasis::zoned: return format_zoned_time(value, event.time_zone); } throw std::logic_error("unknown event time basis"); } [[nodiscard]] std::string time_property_parameters(const Event& event) { if (event.all_day) { return ";VALUE=DATE"; } if (event.time_basis == TimeBasis::zoned) { return ";TZID=" + event.time_zone; } return {}; } [[nodiscard]] std::string recurrence_frequency_name(RecurrenceFrequency frequency) { switch (frequency) { case RecurrenceFrequency::daily: return "DAILY"; case RecurrenceFrequency::weekly: return "WEEKLY"; case RecurrenceFrequency::monthly: return "MONTHLY"; case RecurrenceFrequency::yearly: return "YEARLY"; } throw std::logic_error("unknown recurrence frequency"); } template [[nodiscard]] std::string join_values(const Range& values, Formatter formatter) { std::string result; for (const auto& value : values) { if (!result.empty()) { result += ','; } result += formatter(value); } return result; } [[nodiscard]] std::string serialize_recurrence_rule( const Event& event, const RecurrenceRule& rule) { std::string result = "FREQ=" + recurrence_frequency_name(rule.frequency); if (rule.interval != 1) { result += ";INTERVAL=" + std::to_string(rule.interval); } if (rule.count) { result += ";COUNT=" + std::to_string(*rule.count); } else if (rule.until) { result += ";UNTIL="; if (event.all_day) { result += format_date(*rule.until); } else if (event.time_basis == TimeBasis::floating) { result += format_floating_time(*rule.until); } else { result += format_utc_time(*rule.until); } } if (!rule.by_weekday.empty()) { result += ";BYDAY=" + join_values(rule.by_weekday, [](const ByWeekday& bw) { std::string out; if (bw.ordinal != 0) { out += (bw.ordinal > 0 ? "+" : "") + std::to_string(bw.ordinal); } out += weekday_name(bw.day); return out; }); } if (!rule.by_month_day.empty()) { result += ";BYMONTHDAY=" + join_values(rule.by_month_day, [](int day) { return std::to_string(day); }); } if (!rule.by_month.empty()) { result += ";BYMONTH=" + join_values(rule.by_month, [](unsigned month_number) { return std::to_string(month_number); }); } return result; } [[nodiscard]] std::size_t utf8_boundary(std::string_view value, std::size_t begin, std::size_t limit) { std::size_t end = std::min(value.size(), begin + limit); while (end > begin && end < value.size() && (static_cast(value[end]) & 0xC0U) == 0x80U) { --end; } return end == begin ? std::min(value.size(), begin + limit) : end; } void write_folded(std::ostream& output, std::string_view content_line) { std::size_t offset = 0; bool first = true; do { const std::size_t available = first ? 75 : 74; const std::size_t end = utf8_boundary(content_line, offset, available); if (!first) { output.put(' '); } output.write(content_line.data() + offset, static_cast(end - offset)); output << "\r\n"; offset = end; first = false; } while (offset < content_line.size()); } void write_text_property(std::ostream& output, std::string_view name, std::string_view value) { if (!value.empty()) { write_folded(output, std::string(name) + ":" + escape_text(value)); } } void serialize_calendar(std::ostream& output, std::span events, std::span 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)); if (event.recurrence_id) { const std::string params = event.all_day ? ";VALUE=DATE" : time_property_parameters(event); write_folded(output, "RECURRENCE-ID" + params + ":" + (event.all_day ? format_date(*event.recurrence_id) : format_event_time(event, *event.recurrence_id))); } if (event.all_day) { write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start)); write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end)); } else { const std::string parameters = time_property_parameters(event); write_folded(output, "DTSTART" + parameters + ":" + format_event_time(event, event.start)); write_folded(output, "DTEND" + parameters + ":" + format_event_time(event, event.end)); } if (event.recurrence) { write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence)); } if (!event.recurrence_additions.empty()) { const std::string parameters = time_property_parameters(event); const std::string values = join_values(event.recurrence_additions, [&event](TimePoint addition) { return event.all_day ? format_date(addition) : format_event_time(event, addition); }); write_folded(output, "RDATE" + parameters + ":" + values); } if (!event.recurrence_exceptions.empty()) { const std::string parameters = time_property_parameters(event); const std::string values = join_values(event.recurrence_exceptions, [&event](TimePoint exception) { return event.all_day ? format_date(exception) : format_event_time(event, exception); }); write_folded(output, "EXDATE" + parameters + ":" + values); } write_text_property(output, "SUMMARY", event.title); write_text_property(output, "LOCATION", event.location); write_text_property(output, "DESCRIPTION", event.description); write_text_property(output, "X-NOCAL-CALENDAR-ID", event.calendar_id); write_text_property(output, "X-NOCAL-COLOR", event.color); write_folded(output, "END:VEVENT"); } write_folded(output, "END:VCALENDAR"); } struct FileSnapshot { bool existed = false; std::string bytes; }; [[nodiscard]] bool same_file_state( bool expected_existed, const std::string* expected_bytes, const FileSnapshot& current) noexcept { if (expected_existed != current.existed) { return false; } if (!expected_existed) { return true; } return expected_bytes != nullptr && *expected_bytes == current.bytes; } #if !defined(_WIN32) [[noreturn]] void throw_io_error(std::string_view operation, const std::filesystem::path& path) { const int error = errno; throw std::runtime_error( std::string(operation) + " '" + path.string() + "': " + std::strerror(error)); } class FileDescriptor { public: explicit FileDescriptor(int descriptor = -1) noexcept : descriptor_(descriptor) {} FileDescriptor(const FileDescriptor&) = delete; FileDescriptor& operator=(const FileDescriptor&) = delete; FileDescriptor(FileDescriptor&& other) noexcept : descriptor_(other.release()) {} ~FileDescriptor() { if (descriptor_ >= 0) { ::close(descriptor_); } } [[nodiscard]] int get() const noexcept { return descriptor_; } int release() noexcept { const int result = descriptor_; descriptor_ = -1; return result; } private: int descriptor_; }; class TemporaryFile { public: TemporaryFile(std::filesystem::path path, int descriptor) : path_(std::move(path)), descriptor_(descriptor) {} TemporaryFile(const TemporaryFile&) = delete; TemporaryFile& operator=(const TemporaryFile&) = delete; TemporaryFile(TemporaryFile&& other) noexcept : path_(std::move(other.path_)), descriptor_(std::move(other.descriptor_)), remove_on_destroy_(other.remove_on_destroy_) { other.remove_on_destroy_ = false; } ~TemporaryFile() { if (descriptor_.get() >= 0) { ::close(descriptor_.release()); } if (remove_on_destroy_) { std::error_code ignored; std::filesystem::remove(path_, ignored); } } [[nodiscard]] int descriptor() const noexcept { return descriptor_.get(); } [[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; } void close_checked() { const int descriptor = descriptor_.release(); if (::close(descriptor) != 0) { throw_io_error("unable to close temporary calendar", path_); } } void committed() noexcept { remove_on_destroy_ = false; } private: std::filesystem::path path_; FileDescriptor descriptor_; bool remove_on_destroy_ = true; }; [[nodiscard]] FileSnapshot read_regular_file( const std::filesystem::path& path, std::string_view purpose) { const int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); if (descriptor < 0) { if (errno == ENOENT) { return {}; } throw_io_error(purpose, path); } const FileDescriptor file(descriptor); struct stat metadata {}; if (::fstat(file.get(), &metadata) != 0) { throw_io_error(purpose, path); } if (!S_ISREG(metadata.st_mode)) { throw std::runtime_error( std::string(purpose) + " '" + path.string() + "': not a regular file"); } FileSnapshot snapshot; snapshot.existed = true; if (metadata.st_size > 0) { snapshot.bytes.reserve(static_cast(metadata.st_size)); } char buffer[16 * 1024]; while (true) { const ssize_t count = ::read(file.get(), buffer, sizeof(buffer)); if (count < 0) { if (errno == EINTR) { continue; } throw_io_error(purpose, path); } if (count == 0) { break; } snapshot.bytes.append(buffer, static_cast(count)); } return snapshot; } [[nodiscard]] FileDescriptor lock_destination(const std::filesystem::path& path) { std::filesystem::path lock_path = path; lock_path += ".lock"; const int descriptor = ::open(lock_path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, S_IRUSR | S_IWUSR); if (descriptor < 0) { throw_io_error("unable to open calendar lock", lock_path); } FileDescriptor lock(descriptor); while (::flock(lock.get(), LOCK_EX) != 0) { if (errno != EINTR) { throw_io_error("unable to lock calendar", lock_path); } } return lock; } [[nodiscard]] TemporaryFile create_temporary_file( const std::filesystem::path& directory, const std::filesystem::path& destination) { const std::string pattern_string = (directory / ("." + destination.filename().string() + ".tmp.XXXXXX")).string(); std::vector pattern(pattern_string.begin(), pattern_string.end()); pattern.push_back('\0'); const int descriptor = ::mkstemp(pattern.data()); if (descriptor < 0) { throw_io_error("unable to create temporary calendar", destination); } TemporaryFile temporary{std::filesystem::path{pattern.data()}, descriptor}; const int flags = ::fcntl(descriptor, F_GETFD); if (flags < 0 || ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) != 0) { throw_io_error("unable to secure temporary calendar", temporary.path()); } if (::fchmod(descriptor, S_IRUSR | S_IWUSR) != 0) { throw_io_error("unable to set temporary calendar permissions", temporary.path()); } return temporary; } void write_all(int descriptor, std::string_view bytes, const std::filesystem::path& path) { std::size_t offset = 0; while (offset < bytes.size()) { const ssize_t written = ::write(descriptor, bytes.data() + offset, bytes.size() - offset); if (written < 0) { if (errno == EINTR) { continue; } throw_io_error("unable to write temporary calendar", path); } if (written == 0) { throw std::runtime_error("unable to write temporary calendar '" + path.string() + "': write made no progress"); } offset += static_cast(written); } } [[nodiscard]] TemporaryFile stage_file( const std::filesystem::path& directory, const std::filesystem::path& destination, std::string_view bytes) { TemporaryFile temporary = create_temporary_file(directory, destination); write_all(temporary.descriptor(), bytes, temporary.path()); if (::fsync(temporary.descriptor()) != 0) { throw_io_error("unable to flush temporary calendar", temporary.path()); } temporary.close_checked(); return temporary; } [[nodiscard]] FileDescriptor open_directory(const std::filesystem::path& directory) { const int directory_descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); if (directory_descriptor < 0) { throw_io_error("unable to open calendar directory", directory); } return FileDescriptor(directory_descriptor); } void replace_with_temporary( TemporaryFile& temporary, const std::filesystem::path& destination, std::string_view operation) { if (::rename(temporary.path().c_str(), destination.c_str()) != 0) { throw_io_error(operation, destination); } temporary.committed(); } #else [[nodiscard]] FileSnapshot read_regular_file( const std::filesystem::path& path, std::string_view purpose) { std::error_code error; if (!std::filesystem::exists(path, error)) { if (!error) { return {}; } throw std::runtime_error( std::string(purpose) + " '" + path.string() + "': " + error.message()); } if (!std::filesystem::is_regular_file(path, error) || error) { throw std::runtime_error( std::string(purpose) + " '" + path.string() + "': not a regular file"); } std::ifstream input(path, std::ios::binary); if (!input) { throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'"); } FileSnapshot snapshot; snapshot.existed = true; snapshot.bytes.assign(std::istreambuf_iterator{input}, {}); if (!input.eof()) { throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'"); } return snapshot; } #endif [[noreturn]] void throw_external_change(const std::filesystem::path& path) { throw std::runtime_error( "calendar changed externally; reload before saving '" + path.string() + "'"); } [[nodiscard]] FileSnapshot read_current_file( const std::filesystem::path& path, bool has_expected_revision) { try { return read_regular_file(path, "unable to read current calendar"); } catch (const std::runtime_error&) { // A file that became unreadable or changed type cannot match an exact // revision. Keep the optimistic-concurrency diagnostic actionable. if (has_expected_revision) { throw_external_change(path); } throw; } } void validate_recurrence(const Event& event) { if (!event.recurrence) { return; } const RecurrenceRule& rule = *event.recurrence; const auto invalid = [&event](std::string_view reason) { throw std::invalid_argument("cannot save event '" + event.uid + "' with invalid recurrence: " + std::string(reason)); }; switch (rule.frequency) { case RecurrenceFrequency::daily: case RecurrenceFrequency::weekly: case RecurrenceFrequency::monthly: case RecurrenceFrequency::yearly: break; default: invalid("frequency is invalid"); } if (rule.interval == 0) invalid("INTERVAL must be positive"); if (rule.count && *rule.count == 0) invalid("COUNT must be positive"); if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive"); for (const ByWeekday& bw : rule.by_weekday) { if (!bw.day.ok()) invalid("BYDAY contains an invalid weekday"); } for (int day : rule.by_month_day) { if (day == 0 || day < -31 || day > 31) { invalid("BYMONTHDAY contains an out-of-range day"); } } for (unsigned month_number : rule.by_month) { if (month_number == 0 || month_number > 12) { invalid("BYMONTH contains an out-of-range month"); } } } void validate_event_for_save( const Event& event, std::span vtimezones) { if (event.uid.empty()) { throw std::invalid_argument("cannot save an event with an empty UID"); } if (event.recurrence_id && event.recurrence) { throw std::invalid_argument( "cannot save override event '" + event.uid + "' with both RECURRENCE-ID and RRULE"); } if (event.end < event.start || (event.all_day && event.end == event.start)) { throw std::invalid_argument( "cannot save event '" + event.uid + "' with an invalid interval"); } if (!is_recurring(event) && !event.recurrence_exceptions.empty()) { throw std::invalid_argument( "cannot save event '" + event.uid + "' with EXDATE values but no recurrence set"); } std::vector unique_additions = event.recurrence_additions; std::sort(unique_additions.begin(), unique_additions.end()); if (std::adjacent_find(unique_additions.begin(), unique_additions.end()) != unique_additions.end()) { throw std::invalid_argument( "cannot save event '" + event.uid + "' with duplicate RDATE values"); } std::vector unique_exceptions = event.recurrence_exceptions; std::sort(unique_exceptions.begin(), unique_exceptions.end()); if (std::adjacent_find(unique_exceptions.begin(), unique_exceptions.end()) != unique_exceptions.end()) { throw std::invalid_argument( "cannot save event '" + event.uid + "' with duplicate EXDATE values"); } if (!event.all_day) { switch (event.time_basis) { case TimeBasis::floating: case TimeBasis::utc: case TimeBasis::zoned: break; default: throw std::invalid_argument( "cannot save event '" + event.uid + "' with an invalid time basis"); } if (event.time_basis == TimeBasis::zoned) { if (event.time_zone.empty() || event.time_zone.find_first_of(";:\r\n") != std::string::npos) { throw std::invalid_argument( "cannot save zoned event '" + event.uid + "' without a valid TZID"); } try { (void)locate_zone(event.time_zone); } catch (const std::runtime_error&) { 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( "cannot save event '" + event.uid + "' with a TZID but non-zoned time basis"); } } validate_recurrence(event); } } // namespace LoadResult IcsStore::load(const std::filesystem::path& path) { LoadResult result; FileSnapshot snapshot; try { snapshot = read_regular_file(path, "unable to read calendar"); } catch (const std::runtime_error&) { std::error_code error; result.revision.existed_ = std::filesystem::exists(path, error) && !error; result.warnings.push_back("unable to open '" + path.string() + "' for reading"); result.safe_to_rewrite = false; return result; } result.revision.existed_ = snapshot.existed; if (!snapshot.existed) { return result; } result.revision.contents_ = std::make_shared(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 pending; std::size_t event_start_line = 0; for (std::size_t index = 0; index < lines.size(); ++index) { const std::string_view line = lines[index]; const std::size_t separator = content_separator(line); if (separator == std::string_view::npos) { if (pending) { add_warning(result, index + 1, "ignored malformed content line"); } if (!line.empty()) { mark_unsafe_to_rewrite(result); } continue; } const std::string_view head = line.substr(0, separator); const std::string_view value = line.substr(separator + 1); const std::size_t semicolon = head.find(';'); const std::string name = upper(head.substr(0, semicolon)); const std::string_view parameters = semicolon == std::string_view::npos ? std::string_view{} : head.substr(semicolon + 1); if (name == "BEGIN" && upper(value) == "VEVENT") { if (pending) { add_warning(result, event_start_line, "nested VEVENT; discarded incomplete event"); mark_unsafe_to_rewrite(result); } pending.emplace(); event_start_line = index + 1; continue; } if (name == "END" && upper(value) == "VEVENT") { if (!pending) { add_warning(result, index + 1, "END:VEVENT without BEGIN:VEVENT"); mark_unsafe_to_rewrite(result); continue; } if (!pending->uid || pending->uid->empty()) { add_warning(result, event_start_line, "VEVENT has no UID and was skipped"); mark_unsafe_to_rewrite(result); } else if (!pending->start) { add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped"); mark_unsafe_to_rewrite(result); } else if (pending->end && !same_time_kind(*pending->start, *pending->end)) { add_warning(result, event_start_line, "DTSTART and DTEND value types, time bases, or TZIDs differ; event was skipped"); mark_unsafe_to_rewrite(result); } else { const bool all_day = pending->start->is_date; TimePoint end = pending->start->value; if (pending->end) { end = pending->end->value; } else if (all_day) { const auto next_date = year_month_day{ sys_days{*pending->start->date} + days{1}}; const auto next_midnight = make_local(next_date, 0, 0, 0); if (!next_midnight) { add_warning(result, event_start_line, "could not determine implicit all-day DTEND; event was skipped"); mark_unsafe_to_rewrite(result); pending.reset(); continue; } end = *next_midnight; } if (end < pending->start->value || (all_day && end == pending->start->value)) { add_warning(result, event_start_line, "DTEND is before DTSTART; event was skipped"); mark_unsafe_to_rewrite(result); } else { Event event; event.uid = std::move(*pending->uid); event.title = std::move(pending->title); event.start = pending->start->value; event.end = end; event.all_day = all_day; event.time_basis = all_day ? TimeBasis::floating : pending->start->basis; event.time_zone = all_day ? std::string{} : pending->start->time_zone; event.location = std::move(pending->location); event.description = std::move(pending->description); event.calendar_id = std::move(pending->calendar_id); event.color = std::move(pending->color); if (pending->recurrence_id) { event.recurrence_id = pending->recurrence_id->value; } if (pending->recurrence_rules.size() > 1) { add_warning(result, pending->recurrence_rules[1].line, "multiple RRULE properties are not supported"); mark_unsafe_to_rewrite(result); } else if (!pending->recurrence_rules.empty()) { const PendingProperty& property = pending->recurrence_rules.front(); if (!property.parameters.empty()) { add_warning(result, property.line, "RRULE parameters are not supported"); mark_unsafe_to_rewrite(result); } else { std::string error; event.recurrence = parse_recurrence_rule( property.value, *pending->start, error); if (!event.recurrence) { add_warning(result, property.line, std::move(error)); mark_unsafe_to_rewrite(result); } } } for (const PendingProperty& property : pending->recurrence_dates) { for (const std::string_view addition_value : split(property.value, ',')) { std::string warning; const auto parsed = parse_time( property.parameters, addition_value, warning); if (!parsed || !same_time_kind(*pending->start, *parsed)) { add_warning(result, property.line, parsed ? "RDATE value type, time basis, or TZID does not match DTSTART" : std::move(warning)); mark_unsafe_to_rewrite(result); continue; } if (!warning.empty()) { const bool is_tzid = is_unknown_tzid_warning(warning); add_warning(result, property.line, std::move(warning)); if (!is_tzid) { mark_unsafe_to_rewrite(result); } } if (std::find(event.recurrence_additions.begin(), event.recurrence_additions.end(), parsed->value) == event.recurrence_additions.end()) { event.recurrence_additions.push_back(parsed->value); } } } for (const PendingProperty& property : pending->exception_dates) { for (const std::string_view exception_value : split(property.value, ',')) { std::string warning; const auto parsed = parse_time( property.parameters, exception_value, warning); if (!parsed || !same_time_kind(*pending->start, *parsed)) { add_warning(result, property.line, parsed ? "EXDATE value type, time basis, or TZID does not match DTSTART" : std::move(warning)); mark_unsafe_to_rewrite(result); continue; } if (!warning.empty()) { const bool is_tzid = is_unknown_tzid_warning(warning); add_warning(result, property.line, std::move(warning)); if (!is_tzid) { mark_unsafe_to_rewrite(result); } } if (std::find(event.recurrence_exceptions.begin(), event.recurrence_exceptions.end(), parsed->value) == event.recurrence_exceptions.end()) { event.recurrence_exceptions.push_back(parsed->value); } } } if (!is_recurring(event) && !event.recurrence_exceptions.empty()) { add_warning(result, event_start_line, "EXDATE without a supported RRULE or RDATE cannot be edited safely"); mark_unsafe_to_rewrite(result); } result.events.push_back(std::move(event)); } } pending.reset(); continue; } 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"); const bool supported_property = name == "VERSION" || name == "PRODID" || name == "CALSCALE"; if ((!structural && !supported_property) || (supported_property && !parameters.empty())) { if (!structural && !supported_property) { const std::string feature = (name == "BEGIN" || name == "END") ? name + ":" + upper(value) : name; add_warning(result, index + 1, "unsupported calendar component or property '" + feature + "'"); } mark_unsafe_to_rewrite(result); } continue; } const bool time_property = name == "DTSTART" || name == "DTEND" || name == "RDATE" || name == "EXDATE" || name == "RECURRENCE-ID"; const bool recurrence_property = name == "RRULE"; const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION" || name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID" || name == "X-NOCAL-COLOR"; if ((!time_property && !text_property && !recurrence_property) || (text_property && !parameters.empty())) { if (!time_property && !text_property && !recurrence_property) { const std::string feature = (name == "BEGIN" || name == "END") ? name + ":" + upper(value) : name; add_warning(result, index + 1, "unsupported VEVENT component or property '" + feature + "'"); } mark_unsafe_to_rewrite(result); } if (name == "UID") { pending->uid = unescape_text(value); } else if (name == "SUMMARY") { pending->title = unescape_text(value); } else if (name == "LOCATION") { pending->location = unescape_text(value); } else if (name == "DESCRIPTION") { pending->description = unescape_text(value); } else if (name == "X-NOCAL-CALENDAR-ID") { pending->calendar_id = unescape_text(value); } else if (name == "X-NOCAL-COLOR") { pending->color = unescape_text(value); } else if (name == "RRULE") { pending->recurrence_rules.push_back(PendingProperty{ std::string(parameters), std::string(value), index + 1}); } else if (name == "RDATE") { pending->recurrence_dates.push_back(PendingProperty{ std::string(parameters), std::string(value), index + 1}); } else if (name == "EXDATE") { pending->exception_dates.push_back(PendingProperty{ std::string(parameters), std::string(value), index + 1}); } else if (name == "DTSTART" || name == "DTEND") { std::optional& destination = name == "DTSTART" ? pending->start : pending->end; if (destination) { add_warning(result, index + 1, "duplicate " + name + " property"); mark_unsafe_to_rewrite(result); } std::string warning; auto parsed = parse_time(parameters, value, warning); if (!parsed) { add_warning(result, index + 1, std::move(warning)); 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)); if (!is_tzid) { mark_unsafe_to_rewrite(result); } } destination = *parsed; } } else if (name == "RECURRENCE-ID") { if (pending->recurrence_id) { add_warning(result, index + 1, "duplicate RECURRENCE-ID property"); mark_unsafe_to_rewrite(result); } std::string warning; auto parsed = parse_time(parameters, value, warning); if (!parsed) { add_warning(result, index + 1, std::move(warning)); 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)); if (!is_tzid) { mark_unsafe_to_rewrite(result); } } pending->recurrence_id = *parsed; } } } if (pending) { 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; } std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) { std::filesystem::path backup = path; backup += ".bak"; return backup; } FileRevision IcsStore::save( const std::filesystem::path& path, std::span events, std::optional expected, std::span 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, vtimezones); } std::ostringstream serialized(std::ios::out | std::ios::binary); serialize_calendar(serialized, events, vtimezones); if (!serialized) { throw std::runtime_error("unable to serialize calendar '" + path.string() + "'"); } std::string bytes = serialized.str(); const std::filesystem::path directory = path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path(); std::error_code directory_error; std::filesystem::create_directories(directory, directory_error); if (directory_error) { throw std::runtime_error("unable to create calendar directory '" + directory.string() + "': " + directory_error.message()); } #if !defined(_WIN32) const FileDescriptor lock = lock_destination(path); const FileSnapshot current = read_current_file(path, expected.has_value()); if (expected && !same_file_state( expected->existed_, expected->contents_.get(), current)) { throw_external_change(path); } // Prepare and flush every byte before changing either destination. TemporaryFile calendar_temporary = stage_file(directory, path, bytes); std::optional backup_temporary; const auto backup = backup_path(path); if (current.existed) { backup_temporary.emplace(stage_file(directory, backup, current.bytes)); } const FileDescriptor directory_handle = open_directory(directory); const FileSnapshot rechecked = read_current_file(path, true); if (!same_file_state(current.existed, ¤t.bytes, rechecked)) { throw_external_change(path); } // The advisory lock fully serializes Nocal writers. A program that ignores // it can still replace the path after this comparison and before rename; // portable POSIX rename does not provide a true compare-and-swap primitive. if (backup_temporary) { replace_with_temporary(*backup_temporary, backup, "unable to replace calendar backup"); (void)::fsync(directory_handle.get()); } replace_with_temporary(calendar_temporary, path, "unable to replace calendar"); // The calendar rename is the commit point. Reporting a later directory // fsync failure as an unchanged save would make caller rollback incorrect. (void)::fsync(directory_handle.get()); #else const FileSnapshot current = read_current_file(path, expected.has_value()); if (expected && !same_file_state( expected->existed_, expected->contents_.get(), current)) { throw_external_change(path); } const auto write_replacement = [](const std::filesystem::path& destination, std::string_view replacement) { std::filesystem::path temporary = destination; temporary += ".tmp"; std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output) { throw std::runtime_error("unable to open temporary file '" + temporary.string() + "'"); } output.write(replacement.data(), static_cast(replacement.size())); output.flush(); if (!output) { throw std::runtime_error("failed while writing temporary file '" + temporary.string() + "'"); } output.close(); std::error_code ignored; std::filesystem::remove(destination, ignored); std::filesystem::rename(temporary, destination); }; if (current.existed) { write_replacement(backup_path(path), current.bytes); } write_replacement(path, bytes); #endif FileRevision revision; revision.existed_ = true; revision.contents_ = std::make_shared(std::move(bytes)); return revision; } FileRevision IcsStore::restore_backup( const std::filesystem::path& path, std::optional expected) { if (path.empty() || path.filename().empty()) { throw std::invalid_argument("calendar path must name a file"); } const std::filesystem::path directory = path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path(); const auto backup = backup_path(path); #if !defined(_WIN32) const FileDescriptor lock = lock_destination(path); const FileSnapshot current = read_current_file(path, expected.has_value()); if (expected && !same_file_state( expected->existed_, expected->contents_.get(), current)) { throw_external_change(path); } const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup"); if (!saved.existed) { throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'"); } TemporaryFile temporary = stage_file(directory, path, saved.bytes); const FileDescriptor directory_handle = open_directory(directory); const FileSnapshot rechecked = read_current_file(path, true); if (!same_file_state(current.existed, ¤t.bytes, rechecked)) { throw_external_change(path); } replace_with_temporary(temporary, path, "unable to restore calendar backup"); (void)::fsync(directory_handle.get()); #else const FileSnapshot current = read_current_file(path, expected.has_value()); if (expected && !same_file_state( expected->existed_, expected->contents_.get(), current)) { throw_external_change(path); } const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup"); if (!saved.existed) { throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'"); } std::ofstream output(path, std::ios::binary | std::ios::trunc); if (!output) { throw std::runtime_error("unable to open '" + path.string() + "' for restoring"); } output.write(saved.bytes.data(), static_cast(saved.bytes.size())); output.flush(); if (!output) { throw std::runtime_error("failed while restoring '" + path.string() + "'"); } #endif FileRevision revision; revision.existed_ = true; revision.contents_ = std::make_shared(saved.bytes); return revision; } } // namespace nocal::storage