From 2dab5b97b60ba77eabc51c141396fd96a827f546 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Thu, 23 Jul 2026 19:48:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20CalDAV=20foundation=20=E2=80=94=20HTTP?= =?UTF-8?q?=20methods=20and=20XML=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PROPFIND and REPORT to HttpMethod enum for CalDAV protocol support. Implement minimal XML parser (caldav_xml.hpp) for CalDAV response parsing: namespaced tags, attributes, CDATA, entity references, self-closing elements. Bounded by max depth and size to prevent allocation bombs. --- include/nocal/sync/caldav_xml.hpp | 38 +++++ include/nocal/sync/http.hpp | 2 +- meson.build | 5 + src/sync/caldav_xml.cpp | 262 ++++++++++++++++++++++++++++++ src/sync/curl_http.cpp | 2 + tests/caldav_xml_tests.cpp | 206 +++++++++++++++++++++++ 6 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 include/nocal/sync/caldav_xml.hpp create mode 100644 src/sync/caldav_xml.cpp create mode 100644 tests/caldav_xml_tests.cpp diff --git a/include/nocal/sync/caldav_xml.hpp b/include/nocal/sync/caldav_xml.hpp new file mode 100644 index 0000000..25ca051 --- /dev/null +++ b/include/nocal/sync/caldav_xml.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace nocal::sync { + +// A parsed XML element. +struct XmlElement { + std::string tag; + std::vector> attributes; + std::string text; + std::string cdata; + std::vector children; +}; + +// Parse an XML document string into a tree of XmlElement. +// Returns the root element. Throws std::runtime_error on parse failure. +// Bounded by max_size to prevent allocation bombs. +[[nodiscard]] XmlElement parse_xml( + std::string_view document, std::size_t max_size = 4 * 1024 * 1024); + +// Find all children with the given tag name. +[[nodiscard]] std::vector find_children( + const XmlElement& parent, std::string_view tag); + +// Get first child text or empty string. +[[nodiscard]] std::string_view get_child_text( + const XmlElement& parent, std::string_view tag); + +// Get attribute value or empty string. +[[nodiscard]] std::string_view get_attribute( + const XmlElement& elem, std::string_view name); + +} // namespace nocal::sync diff --git a/include/nocal/sync/http.hpp b/include/nocal/sync/http.hpp index cf958df..a72eed3 100644 --- a/include/nocal/sync/http.hpp +++ b/include/nocal/sync/http.hpp @@ -5,7 +5,7 @@ namespace nocal::sync { -enum class HttpMethod { get, post, put, patch, delete_ }; +enum class HttpMethod { get, post, put, patch, delete_, propfind, report }; struct HttpHeader { std::string name; diff --git a/meson.build b/meson.build index 0696abb..5c9c38d 100644 --- a/meson.build +++ b/meson.build @@ -26,6 +26,7 @@ nocal_sources = files( 'src/domain/month_layout.cpp', 'src/storage/ics_store.cpp', 'src/storage/sync_cache.cpp', + 'src/sync/caldav_xml.cpp', 'src/sync/curl_http.cpp', 'src/sync/desktop_oauth.cpp', 'src/sync/microsoft_account.cpp', @@ -114,6 +115,10 @@ unicode_tests = executable('unicode-tests', 'tests/unicode_tests.cpp', dependencies: nocal_dep) test('nocal:unicode', unicode_tests) +caldav_xml_tests = executable('caldav-xml-tests', + 'tests/caldav_xml_tests.cpp', dependencies: nocal_dep) +test('caldav-xml', caldav_xml_tests) + test('cli-recovery', shell, args: [files('tests/cli_recovery_test.sh'), nocal_executable]) test('cli-transfer', shell, diff --git a/src/sync/caldav_xml.cpp b/src/sync/caldav_xml.cpp new file mode 100644 index 0000000..c4e7d95 --- /dev/null +++ b/src/sync/caldav_xml.cpp @@ -0,0 +1,262 @@ +#include "nocal/sync/caldav_xml.hpp" + +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +constexpr std::size_t max_depth = 64; + +std::string_view trim(std::string_view s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) s.remove_prefix(1); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.remove_suffix(1); + return s; +} + +std::string replace_entities(std::string_view s) { + std::string result; + result.reserve(s.size()); + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '&' && i + 4 < s.size() && s[i + 1] == 'l' && s[i + 2] == 't' && s[i + 3] == ';') { + result.push_back('<'); + i += 3; + } else if (s[i] == '&' && i + 4 < s.size() && s[i + 1] == 'g' && s[i + 2] == 't' && s[i + 3] == ';') { + result.push_back('>'); + i += 3; + } else if (s[i] == '&' && i + 5 < s.size() && s[i + 1] == 'a' && s[i + 2] == 'm' + && s[i + 3] == 'p' && s[i + 4] == ';') { + result.push_back('&'); + i += 4; + } else if (s[i] == '&' && i + 6 < s.size() && s[i + 1] == 'q' && s[i + 2] == 'u' + && s[i + 3] == 'o' && s[i + 4] == 't' && s[i + 5] == ';') { + result.push_back('"'); + i += 5; + } else if (s[i] == '&' && i + 6 < s.size() && s[i + 1] == 'a' && s[i + 2] == 'p' + && s[i + 3] == 'o' && s[i + 4] == 's' && s[i + 5] == ';') { + result.push_back('\''); + i += 5; + } else { + result.push_back(s[i]); + } + } + return result; +} + +class XmlParser { +public: + explicit XmlParser(std::string_view doc) : doc_(doc), pos_(0) {} + + [[nodiscard]] XmlElement parse_root() { + skip_whitespace(); + while (pos_ < doc_.size()) { + skip_whitespace(); + if (pos_ >= doc_.size()) break; + if (doc_[pos_] == '<') { + if (pos_ + 1 < doc_.size() && doc_[pos_ + 1] == '?') { + skip_processing_instruction(); + } else if (pos_ + 4 < doc_.size() && doc_.substr(pos_, 4) == ""); + require(root.tag == "doc", "comment skipped"); +} + +void test_find_children() { + const auto root = nocal::sync::parse_xml("123"); + const auto found = nocal::sync::find_children(root, "a"); + require(found.size() == 2, "two 'a' children"); + require(found[0]->text == "1", "first a"); + require(found[1]->text == "3", "second a"); +} + +void test_get_child_text() { + const auto root = nocal::sync::parse_xml("Bob"); + require(nocal::sync::get_child_text(root, "name") == "Bob", "child text"); + require(nocal::sync::get_child_text(root, "missing").empty(), "missing child"); +} + +void test_empty_document() { + bool threw = false; + try { + (void)nocal::sync::parse_xml(""); + } catch (const std::runtime_error&) { + threw = true; + } + require(threw, "empty document throws"); +} + +void test_unclosed_tag() { + bool threw = false; + try { + (void)nocal::sync::parse_xml(""); + } catch (const std::runtime_error&) { + threw = true; + } + require(threw, "unclosed tag throws"); +} + +void test_mismatched_tag() { + bool threw = false; + try { + (void)nocal::sync::parse_xml(""); + } catch (const std::runtime_error&) { + threw = true; + } + require(threw, "mismatched tag throws"); +} + +void test_max_depth() { + bool threw = false; + std::string deep(""); + for (int i = 0; i < 100; ++i) { + deep += ""; + } + deep += "x"; + for (int i = 0; i < 100; ++i) { + deep += ""; + } + deep += ""; + try { + (void)nocal::sync::parse_xml(deep); + } catch (const std::runtime_error&) { + threw = true; + } + require(threw, "excessive depth throws"); +} + +void test_caldav_propfind_response() { + const std::string doc = R"( + + + /calendars/user/calendar1/ + + HTTP/1.1 200 OK + + My Calendar + + sync-token-123 + + + +)"; + + const auto root = nocal::sync::parse_xml(doc); + require(root.tag == "D:multistatus", "root tag"); + require(root.children.size() == 1, "one response"); + const auto& response = root.children[0]; + require(response.tag == "D:response", "response element"); + const auto hrefs = nocal::sync::find_children(response, "D:href"); + require(hrefs.size() == 1 && hrefs[0]->text == "/calendars/user/calendar1/", "href"); + // D:displayname is inside D:propstat > D:prop, so traverse down + const auto& propstat = response.children[1]; + require(propstat.tag == "D:propstat", "propstat element"); + const auto& prop = propstat.children[1]; + require(prop.tag == "D:prop", "prop element"); + const auto displaynames = nocal::sync::find_children(prop, "D:displayname"); + require(displaynames.size() == 1 && displaynames[0]->text == "My Calendar", "displayname"); + const auto sync_tokens = nocal::sync::find_children(prop, "D:sync-token"); + require(sync_tokens.size() == 1 && sync_tokens[0]->text == "sync-token-123", "sync-token"); +} + +void test_max_size() { + bool threw = false; + try { + const std::string large(5 * 1024 * 1024, 'x'); + (void)nocal::sync::parse_xml("" + large + "", 1024); + } catch (const std::runtime_error&) { + threw = true; + } + require(threw, "oversized document throws"); +} + +} // namespace + +int main() { + try { + test_simple_element(); + test_namespace_prefix(); + test_attributes(); + test_cdata(); + test_self_closing(); + test_nested_elements(); + test_entity_references(); + test_processing_instruction(); + test_comment_skipped(); + test_find_children(); + test_get_child_text(); + test_empty_document(); + test_unclosed_tag(); + test_mismatched_tag(); + test_max_depth(); + test_caldav_propfind_response(); + test_max_size(); + std::cout << "caldav_xml_tests: ok\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "caldav_xml_tests: " << error.what() << '\n'; + return 1; + } +}