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

View File

@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace nocal::sync {
// A parsed XML element.
struct XmlElement {
std::string tag;
std::vector<std::pair<std::string, std::string>> attributes;
std::string text;
std::string cdata;
std::vector<XmlElement> 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<const XmlElement*> 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

View File

@@ -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;

View File

@@ -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,

262
src/sync/caldav_xml.cpp Normal file
View File

@@ -0,0 +1,262 @@
#include "nocal/sync/caldav_xml.hpp"
#include <algorithm>
#include <cctype>
#include <stdexcept>
#include <string>
#include <string_view>
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<unsigned char>(s.front()))) s.remove_prefix(1);
while (!s.empty() && std::isspace(static_cast<unsigned char>(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) == "<!--") {
skip_comment();
} else {
elements_.push_back(parse_element(1));
}
} else {
++pos_;
}
}
if (elements_.empty()) {
throw std::runtime_error("XML document contains no root element");
}
return std::move(elements_[0]);
}
private:
std::string_view doc_;
std::size_t pos_;
std::vector<XmlElement> elements_;
void skip_whitespace() {
while (pos_ < doc_.size() && std::isspace(static_cast<unsigned char>(doc_[pos_]))) {
++pos_;
}
}
void skip_processing_instruction() {
if (pos_ + 2 <= doc_.size() && doc_[pos_] == '<' && doc_[pos_ + 1] == '?') {
pos_ += 2;
while (pos_ + 1 < doc_.size() && !(doc_[pos_] == '?' && doc_[pos_ + 1] == '>')) {
++pos_;
}
if (pos_ + 1 < doc_.size()) pos_ += 2;
}
}
void skip_comment() {
if (pos_ + 4 <= doc_.size() && doc_.substr(pos_, 4) == "<!--") {
pos_ += 4;
while (pos_ + 2 < doc_.size() && !(doc_[pos_] == '-' && doc_[pos_ + 1] == '-'
&& doc_[pos_ + 2] == '>')) {
++pos_;
}
if (pos_ + 2 < doc_.size()) pos_ += 3;
}
}
std::string read_tag_name() {
std::string tag;
while (pos_ < doc_.size() && !std::isspace(static_cast<unsigned char>(doc_[pos_]))
&& doc_[pos_] != '>' && doc_[pos_] != '/' && doc_[pos_] != '<') {
tag.push_back(doc_[pos_++]);
}
if (tag.empty()) {
throw std::runtime_error("XML: empty tag name at position " + std::to_string(pos_));
}
return tag;
}
std::vector<std::pair<std::string, std::string>> read_attributes() {
std::vector<std::pair<std::string, std::string>> attrs;
skip_whitespace();
while (pos_ < doc_.size() && doc_[pos_] != '>' && doc_[pos_] != '/') {
std::string name;
while (pos_ < doc_.size() && !std::isspace(static_cast<unsigned char>(doc_[pos_]))
&& doc_[pos_] != '=') {
name.push_back(doc_[pos_++]);
}
skip_whitespace();
if (pos_ >= doc_.size() || doc_[pos_] != '=') {
throw std::runtime_error("XML: expected '=' after attribute name at position "
+ std::to_string(pos_));
}
++pos_;
skip_whitespace();
if (pos_ >= doc_.size() || (doc_[pos_] != '"' && doc_[pos_] != '\'')) {
throw std::runtime_error("XML: expected quoted attribute value at position "
+ std::to_string(pos_));
}
const char quote = doc_[pos_++];
std::string value;
while (pos_ < doc_.size() && doc_[pos_] != quote) {
value.push_back(doc_[pos_++]);
}
if (pos_ >= doc_.size()) {
throw std::runtime_error("XML: unterminated attribute value at position "
+ std::to_string(pos_));
}
++pos_;
attrs.emplace_back(std::move(name), std::move(value));
skip_whitespace();
}
return attrs;
}
XmlElement parse_element(std::size_t depth) {
if (depth > max_depth) {
throw std::runtime_error("XML: maximum nesting depth exceeded");
}
if (pos_ >= doc_.size() || doc_[pos_] != '<') {
throw std::runtime_error("XML: expected '<' at position " + std::to_string(pos_));
}
++pos_;
const std::string tag = read_tag_name();
const auto attributes = read_attributes();
skip_whitespace();
XmlElement elem;
elem.tag = tag;
elem.attributes = std::move(attributes);
if (pos_ < doc_.size() && doc_[pos_] == '/') {
++pos_;
if (pos_ < doc_.size() && doc_[pos_] == '>') {
++pos_;
}
return elem;
}
if (pos_ >= doc_.size() || doc_[pos_] != '>') {
throw std::runtime_error("XML: expected '>' at position " + std::to_string(pos_));
}
++pos_;
while (pos_ < doc_.size()) {
if (pos_ + 9 <= doc_.size() && doc_.substr(pos_, 9) == "<![CDATA[") {
pos_ += 9;
std::size_t start = pos_;
while (pos_ + 2 < doc_.size() && !(doc_[pos_] == ']' && doc_[pos_ + 1] == ']'
&& doc_[pos_ + 2] == '>')) {
++pos_;
}
elem.cdata.append(doc_.data() + start, pos_ - start);
if (pos_ + 2 < doc_.size()) pos_ += 3;
} else if (pos_ < doc_.size() && doc_[pos_] == '<' && pos_ + 1 < doc_.size()
&& doc_[pos_ + 1] == '/') {
break;
} else if (pos_ < doc_.size() && doc_[pos_] == '<') {
elem.children.push_back(parse_element(depth + 1));
} else {
std::size_t start = pos_;
while (pos_ < doc_.size() && doc_[pos_] != '<') {
++pos_;
}
elem.text.append(doc_.data() + start, pos_ - start);
}
}
elem.text = replace_entities(trim(elem.text));
if (pos_ < doc_.size() && doc_[pos_] == '<' && pos_ + 1 < doc_.size()
&& doc_[pos_ + 1] == '/') {
pos_ += 2;
std::string close_tag;
while (pos_ < doc_.size() && doc_[pos_] != '>') {
close_tag.push_back(doc_[pos_++]);
}
close_tag = trim(close_tag);
if (close_tag != tag) {
throw std::runtime_error("XML: mismatched tags '" + tag + "' and '" + close_tag + "'");
}
if (pos_ < doc_.size()) ++pos_;
} else {
throw std::runtime_error("XML: unclosed element '" + tag + "'");
}
return elem;
}
};
} // namespace
XmlElement parse_xml(const std::string_view document, const std::size_t max_size) {
if (document.size() > max_size) {
throw std::runtime_error("XML document exceeds maximum size");
}
XmlParser parser(document);
return parser.parse_root();
}
std::vector<const XmlElement*> find_children(const XmlElement& parent, const std::string_view tag) {
std::vector<const XmlElement*> result;
for (const auto& child : parent.children) {
if (child.tag == tag) {
result.push_back(&child);
}
}
return result;
}
std::string_view get_child_text(const XmlElement& parent, const std::string_view tag) {
for (const auto& child : parent.children) {
if (child.tag == tag) {
return child.cdata.empty() ? child.text : child.cdata;
}
}
return {};
}
std::string_view get_attribute(const XmlElement& elem, const std::string_view name) {
for (const auto& [n, v] : elem.attributes) {
if (n == name) {
return v;
}
}
return {};
}
} // namespace nocal::sync

View File

@@ -329,6 +329,8 @@ void set_option(CURL* handle, CURLoption option, Value value) {
case HttpMethod::put: return "PUT";
case HttpMethod::patch: return "PATCH";
case HttpMethod::delete_: return "DELETE";
case HttpMethod::propfind: return "PROPFIND";
case HttpMethod::report: return "REPORT";
}
throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request method");
}

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;
}
}