feat: CalDAV foundation — HTTP methods and XML parser

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.
This commit is contained in:
2026-07-23 19:48:38 +01:00
parent 3b73badf5d
commit 2dab5b97b6
6 changed files with 514 additions and 1 deletions

206
tests/caldav_xml_tests.cpp Normal file
View File

@@ -0,0 +1,206 @@
#include "nocal/sync/caldav_xml.hpp"
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
namespace {
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string(message));
}
}
void test_simple_element() {
const auto root = nocal::sync::parse_xml("<root><child>text</child></root>");
require(root.tag == "root", "root tag");
require(root.children.size() == 1, "one child");
require(root.children[0].tag == "child", "child tag");
require(root.children[0].text == "text", "child text");
}
void test_namespace_prefix() {
const auto root = nocal::sync::parse_xml("<D:propfind><D:prop><C:displayname/></D:prop></D:propfind>");
require(root.tag == "D:propfind", "namespaced root");
require(root.children[0].tag == "D:prop", "namespaced child");
require(root.children[0].children[0].tag == "C:displayname", "nested namespaced");
}
void test_attributes() {
const auto root = nocal::sync::parse_xml(R"(<elem href="/path" name="test" />)");
require(root.tag == "elem", "tag");
require(root.attributes.size() == 2, "two attributes");
require(nocal::sync::get_attribute(root, "href") == "/path", "href");
require(nocal::sync::get_attribute(root, "name") == "test", "name");
}
void test_cdata() {
const auto root = nocal::sync::parse_xml("<data><![CDATA[<xml>content</xml>]]></data>");
require(root.cdata == "<xml>content</xml>", "CDATA content preserved");
}
void test_self_closing() {
const auto root = nocal::sync::parse_xml("<root><a/><b /></root>");
require(root.children.size() == 2, "two self-closing children");
require(root.children[0].children.empty(), "no nested children in self-closing");
}
void test_nested_elements() {
const auto root = nocal::sync::parse_xml("<a><b><c>deep</c></b></a>");
require(root.children[0].children[0].text == "deep", "deep nesting");
}
void test_entity_references() {
const auto root = nocal::sync::parse_xml("<text>&lt;hello&gt; &amp; world</text>");
require(root.text == "<hello> & world", "entities replaced");
}
void test_processing_instruction() {
const auto root = nocal::sync::parse_xml("<?xml version=\"1.0\"?><doc/>");
require(root.tag == "doc", "PI skipped, root found");
}
void test_comment_skipped() {
const auto root = nocal::sync::parse_xml("<!-- comment --><doc/>");
require(root.tag == "doc", "comment skipped");
}
void test_find_children() {
const auto root = nocal::sync::parse_xml("<root><a>1</a><b>2</b><a>3</a></root>");
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("<root><name>Bob</name></root>");
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("<root><child>");
} 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("<root><child></wrong>");
} catch (const std::runtime_error&) {
threw = true;
}
require(threw, "mismatched tag throws");
}
void test_max_depth() {
bool threw = false;
std::string deep("<a>");
for (int i = 0; i < 100; ++i) {
deep += "<a>";
}
deep += "x";
for (int i = 0; i < 100; ++i) {
deep += "</a>";
}
deep += "</a>";
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"(<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="CALDAV:" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/calendars/user/calendar1/</D:href>
<D:propstat>
<D:status>HTTP/1.1 200 OK</D:status>
<D:prop>
<D:displayname>My Calendar</D:displayname>
<D:resourcetype><D:collection><C:calendar/></D:collection></D:resourcetype>
<D:sync-token>sync-token-123</D:sync-token>
</D:prop>
</D:propstat>
</D:response>
</D:multistatus>)";
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("<root>" + large + "</root>", 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;
}
}