ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
webdav.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file webdav.cpp
6 * @brief WebDAV (RFC 4918) filesystem-backed request handler for PC.
7 *
8 * Split out of protocore.cpp (single-purpose server files). The pure WebDAV core - method
9 * classification, the 207 Multi-Status XML builder, header parsing - lives in
10 * services/file_transfer/webdav/webdav.{h,cpp} and is host-tested; this file is the PC glue that
11 * needs a real filesystem (PROPFIND/PUT/COPY/MOVE/... over an fs::FS subtree). WEBDAV requires
12 * FILE_SERVING (enforced in protocore_config.h), so the file-serving helpers it borrows are always
13 * present. Behavior is identical to the pre-split code - a pure move.
14 */
15
16#include "services/file_transfer/webdav/webdav.h" // the pure WebDAV core
17#include "network_drivers/transport/tcp.h" // conn_pool
18#include "protocore.h"
20#include "services/system/clock.h" // pc_millis() for lock timeouts
22#include "shared_primitives/strbuf.h" // pc_sb frame builder
23#include <string.h>
24
25#if PC_ENABLE_WEBDAV
26// ---------------------------------------------------------------------------
27// WebDAV (RFC 4918) - filesystem-backed request handling. The pure core
28// (method classification + 207 XML builder + header parsing) lives in
29// services/file_transfer/webdav/webdav.{h,cpp} and is host-tested; this part needs a real FS.
30// ---------------------------------------------------------------------------
31
32// The parser's streaming-body sink is a SINGLE global hook (http_parser_set_stream_hooks): the
33// last registrar wins, so an OTA or upload service registering after dav() would silently take
34// the sink away and a bodied PUT to a DAV route would buffer (bounded by BODY_BUF_SIZE) instead
35// of streaming. protocore_config.h documents "do not combine" but nothing enforced it; the buffered-PUT
36// fallback below depends on it, so enforce it here rather than leaving it to the reader.
37#if PC_ENABLE_OTA || PC_ENABLE_UPLOAD
38#error "PC_ENABLE_WEBDAV cannot be combined with PC_ENABLE_OTA or PC_ENABLE_UPLOAD: the parser's \
39streaming-body sink is a single global hook, so whichever registers last silently disables the others."
40#endif
41
42// A Depth-1 PROPFIND stops on whichever comes first: the 207 buffer filling, or PC_WEBDAV_MAX_ENTRIES
43// children. Both are plain #ifndef knobs and nothing related them, so pin the relationship the listing
44// loop's bound relies on - the buffer must fill first.
45//
46// The fixed text of one <D:response> (pc_webdav_ms_entry) is 204 bytes: 27 href prologue + 66
47// prop/resourcetype opening + 18 resourcetype close + 93 propstat/response close. Add the href itself
48// (>= 1 byte) and the shortest possible element is 205; a collection adds <D:collection/>, a file adds
49// getcontentlength/getcontenttype, and both add getlastmodified, so every real shape is larger. 192 is
50// therefore a safe floor: BUF_SIZE / 192 over-estimates how many entries the buffer can ever hold, and
51// it still has to come out under MAX_ENTRIES. (It over-estimates further because ms_begin's prologue and
52// ms_end's epilogue also come out of the same buffer.)
53static const size_t PC_WEBDAV_MIN_ENTRY_BYTES = 192;
54static_assert(PC_WEBDAV_BUF_SIZE / PC_WEBDAV_MIN_ENTRY_BYTES < PC_WEBDAV_MAX_ENTRIES,
55 "PC_WEBDAV_BUF_SIZE is large enough to hold PC_WEBDAV_MAX_ENTRIES entries: raise "
56 "PC_WEBDAV_MAX_ENTRIES or lower PC_WEBDAV_BUF_SIZE so the buffer bound stays the "
57 "binding one (see the PROPFIND listing loop).");
58
59// WebDAV response scratch, owned by one instance (internal linkage): the 207 Multi-Status build
60// buffer (BSS). One named owner, unreachable from any other translation unit.
61struct DavBufCtx
62{
63 char buf[PC_WEBDAV_BUF_SIZE];
64};
65static DavBufCtx s_dav;
66
67// http_rfc1123() lives in the FILE_SERVING section above (WEBDAV requires
68// FILE_SERVING), shared by both; used here for getlastmodified / creationdate.
69
70// Join an FS root and a subpath into @p out (mirrors serve_static_request's
71// separator handling). Returns false on overflow.
72static bool dav_join(const char *root, const char *sub, char *out, size_t cap)
73{
74 size_t rlen = strnlen(root, MAX_PATH_LEN);
75 bool root_slash = (rlen > 0 && root[rlen - 1] == '/');
76 if (root_slash && sub[0] == '/')
77 {
78 sub++;
79 }
80 bool sub_slash = (sub[0] == '/');
81 const char *sep = (root_slash || sub_slash) ? "" : "/";
82 pc_sb sb_out = {out, cap, 0, true};
83 pc_sb_put(&sb_out, root);
84 pc_sb_put(&sb_out, sep);
85 pc_sb_put(&sb_out, sub);
86 int wn = (int)pc_sb_finish(&sb_out);
87 // wn <= 0 cannot fire: snprintf only returns negative on an encoding error, which "%s%s%s"
88 // cannot raise, and sep is "/" whenever root and sub are both empty, so the shortest join is
89 // one byte. The truncation half (wn >= cap) is exercised.
90 return wn > 0 && wn < (int)cap; // GCOVR_EXCL_BR_LINE wn <= 0 unreachable (see above)
91}
92
93// The basename of an FS entry name (cores differ: name() may be a full path or a
94// bare name).
95static const char *dav_basename(const char *name)
96{
97 const char *slash = strrchr(name, '/');
98 // The bare-name half is an ESP32-core shape: the host FS mock's File::name() always returns the
99 // node's full path, so strrchr never comes back null here.
100 return slash ? slash + 1 : name; // GCOVR_EXCL_BR_LINE bare-name half is core-specific (see above)
101}
102
103// Recursively delete a file or directory tree (bounded depth). Re-opens the
104// directory after each child removal so iteration is never mutated underneath us.
105static bool dav_rm_recursive(fs::FS &fsys, const char *path, int depth)
106{
107 if (depth > 8)
108 {
109 return false; // refuse pathologically deep trees rather than overflow the stack
110 }
111 fs::File d = fsys.open(path, "r");
112 if (!d)
113 {
114 return false;
115 }
116 if (!d.isDirectory())
117 {
118 d.close();
119 return fsys.remove(path);
120 }
121 for (;;)
122 {
123 fs::File c = d.openNextFile();
124 if (!c)
125 {
126 break;
127 }
128 char cp[256];
129 pc_sb sb_cp = {cp, sizeof(cp), 0, true};
130 pc_sb_put(&sb_cp, path);
131 pc_sb_put(&sb_cp, "/");
132 pc_sb_put(&sb_cp, dav_basename(c.name()));
133 int wn = (int)pc_sb_finish(&sb_cp);
134 c.close();
135 // GCOVR_EXCL_START neither half can fire on a host build. snprintf cannot return negative
136 // for "%s/%s", and the child path cannot overflow cp[256]: `path` had to open, so it is a
137 // path the FS mock actually stores (its node paths are capped at 160 bytes), and the child's
138 // basename is what is left of that same 160-byte cap after `path` - so cp stays under 160.
139 // On a real core (LittleFS: 255-byte names under a 240-byte mount root) it is reachable,
140 // which is why the guard is here.
141 if (wn <= 0 || wn >= (int)sizeof(cp))
142 {
143 d.close();
144 return false;
145 }
146 // GCOVR_EXCL_STOP
147 if (!dav_rm_recursive(fsys, cp, depth + 1))
148 {
149 d.close();
150 return false;
151 }
152 d.close();
153 d = fsys.open(path, "r"); // reset the directory cursor after the deletion
154 // GCOVR_EXCL_START TOCTOU re-open guard: `path` opened moments ago, so only a concurrent
155 // delete / FS fault fails it here. The host mock's open-failure hook is a single stored path
156 // that applies to every open, so it cannot fail this re-open while letting the first open
157 // above succeed - there is no way to drive this arm from a host test.
158 if (!d)
159 {
160 return false;
161 }
162 // GCOVR_EXCL_STOP
163 }
164 d.close();
165 return fsys.rmdir(path);
166}
167
168// Recursively copy a file or directory tree from @p src to @p dst (bounded depth).
169// Unlike dav_rm_recursive we cannot re-open + take the first child each step (the
170// source is not consumed, so that would loop forever); instead we re-open and skip
171// to child #idx, which is safe even if a core invalidates an open dir handle across
172// the writes the copy makes to the destination tree.
173static bool dav_copy_recursive(fs::FS &fsys, const char *src, const char *dst, int depth)
174{
175 if (depth > 8)
176 {
177 return false; // refuse pathologically deep trees rather than overflow the stack
178 }
179
180 fs::File s = fsys.open(src, "r");
181 if (!s)
182 {
183 return false;
184 }
185 if (!s.isDirectory())
186 {
187 fs::File d = fsys.open(dst, "w");
188 if (!d)
189 {
190 s.close();
191 return false;
192 }
193 uint8_t cbuf[FILE_CHUNK_SIZE];
194 size_t cn;
195 while ((cn = s.read(cbuf, sizeof(cbuf))) > 0)
196 {
197 d.write(cbuf, cn);
198 }
199 s.close();
200 d.close();
201 return true;
202 }
203 s.close();
204
205 if (!fsys.mkdir(dst)) // create the destination collection (caller cleared any existing dst)
206 {
207 return false;
208 }
209
210 for (int idx = 0;; idx++)
211 {
212 fs::File d = fsys.open(src, "r");
213 // GCOVR_EXCL_START TOCTOU re-open guard, same shape as dav_rm_recursive's: `src` opened at
214 // the top of this call, so only a concurrent delete / FS fault fails it here, and the host
215 // mock's open-failure hook is one stored path applied to every open - it cannot fail this
216 // re-open while letting the first one succeed.
217 if (!d)
218 {
219 return false;
220 }
221 // GCOVR_EXCL_STOP
222 fs::File c;
223 for (int i = 0; i <= idx; i++)
224 {
225 c = d.openNextFile();
226 if (!c)
227 {
228 break;
229 }
230 }
231 if (!c)
232 {
233 d.close();
234 break; // no child at this index - done
235 }
236 char base[128];
237 pc_sb sb_base = {base, sizeof(base), 0, true};
238 pc_sb_put(&sb_base, dav_basename(c.name()));
239 if (pc_sb_finish(&sb_base) == 0)
240 {
241 base[0] = '\0';
242 }
243 c.close();
244 d.close();
245
246 char sp[256];
247 char dp[256];
248 pc_sb sb_sp = {sp, sizeof(sp), 0, true};
249 pc_sb_put(&sb_sp, src);
250 pc_sb_put(&sb_sp, "/");
251 pc_sb_put(&sb_sp, base);
252 int wn1 = (int)pc_sb_finish(&sb_sp);
253 pc_sb sb_dp = {dp, sizeof(dp), 0, true};
254 pc_sb_put(&sb_dp, dst);
255 pc_sb_put(&sb_dp, "/");
256 pc_sb_put(&sb_dp, base);
257 int wn2 = (int)pc_sb_finish(&sb_dp);
258 // GCOVR_EXCL_START none of the four halves can fire on a host build. snprintf cannot return
259 // negative for "%s/%s"; and neither child path can reach 256 bytes, because `src` had to open
260 // (so it is within the FS mock's 160-byte node-path cap, and `base` is what remains of that
261 // cap) while `dst` is the mount root plus a Destination header value, which the parser caps
262 // at MAX_VAL_LEN. Reachable on a real core, where names and roots are far longer.
263 if (wn1 <= 0 || wn1 >= (int)sizeof(sp) || wn2 <= 0 || wn2 >= (int)sizeof(dp))
264 {
265 return false;
266 }
267 // GCOVR_EXCL_STOP
268 if (!dav_copy_recursive(fsys, sp, dp, depth + 1))
269 {
270 return false;
271 }
272 }
273 return true;
274}
275
276// Map a WebDAV request path to its on-disk path under the mount @p r. Strips the
277// mount prefix, rejects traversal, joins onto the FS root, and drops a trailing
278// '/'. Returns 0 on success, else the HTTP error code (403 traversal, 414 too
279// long) - the single source of truth for the path check, shared by the request
280// handler and the streaming-PUT begin hook.
281static int dav_resolve_path(const Route *r, const char *reqpath, char *out, size_t cap)
282{
283 size_t plen = strnlen(r->path, MAX_PATH_LEN);
284 // plen == 0 is unreachable: dav() always stores at least "*" - it appends the wildcard when the
285 // prefix lacks one, so even dav("") yields a one-character pattern.
286 if (plen > 0 && r->path[plen - 1] == '*') // GCOVR_EXCL_BR_LINE plen == 0 unreachable (see above)
287 {
288 plen--;
289 }
290 // GCOVR_EXCL_BR_START the "" arm is unreachable: both callers reached here through
291 // path_matches() against this same route, which already required reqpath to carry the mount
292 // prefix, so the length test always holds. Kept so a future caller that resolves without
293 // matching first still cannot index past the end of reqpath.
294 const char *sub = (strnlen(reqpath, MAX_PATH_LEN) >= plen) ? reqpath + plen : "";
295 // GCOVR_EXCL_BR_STOP
296 if (strstr(sub, ".."))
297 {
298 return 403;
299 }
300 const char *root = r->static_root ? r->static_root : "";
301 if (!dav_join(root, sub, out, cap))
302 {
303 return 414;
304 }
305 size_t fpl = strnlen(out, cap);
306 if (fpl > 1 && out[fpl - 1] == '/')
307 {
308 out[fpl - 1] = '\0';
309 }
310 return 0;
311}
312
313#if PC_ENABLE_STREAM_BODY
314// Per-connection streaming-PUT state for WebDAV: each slot streams its body to its
315// own file, so concurrent PUTs never clobber one another, and a transfer is never
316// bounded by BODY_BUF_SIZE. Indexed by the request's slot (req - http_pool).
317struct DavPut
318{
319 fs::File file; ///< destination file for this slot's PUT.
320 bool active; ///< file opened for the current PUT.
321 bool error; ///< a write (or the open) failed.
322 bool existed; ///< target existed before this PUT (204 vs 201).
323 bool locked; ///< a lock blocked this PUT: consume the body but write nothing, then answer 423.
324 size_t written; ///< bytes written so far.
325};
326
327// WebDAV streaming-PUT state, owned by one instance (internal linkage): the serving instance
328// and the per-slot destination-file state (each slot streams to its own file, so concurrent
329// PUTs never clobber one another). One named owner, unreachable from any other TU.
330struct DavPutCtx
331{
332 PC *stream_srv = nullptr;
333 DavPut put[MAX_CONNS];
334};
335static DavPutCtx s_davput;
336
337// The server-global WebDAV lock state, owned by one instance (internal linkage): the lock table (RFC 4918
338// §6-7). Zero-initialized, so every slot starts inactive and nothing is locked until a LOCK stores a token.
339struct DavLockCtx
340{
341 DavLockTable table;
342};
343static DavLockCtx s_dav_lock;
344
345// True if a write to the URL @p path is blocked by a lock the request does not present a token for. The
346// token, if any, comes from the request's If header (RFC 4918 §10.4 / §7).
347static bool dav_write_blocked(HttpReq *req, const char *path)
348{
349 const char *if_hdr = http_get_header(req, "If");
350 char tok[PC_DAV_LOCK_TOKEN_MAX];
351 const char *presented = (if_hdr && pc_dav_if_token(if_hdr, tok, sizeof(tok))) ? tok : nullptr;
352 return !pc_dav_lock_can_write(&s_dav_lock.table, path, presented);
353}
354
355// True if the (always NUL-terminated) request body contains @p needle - used to spot a <shared> lockscope.
356static bool dav_body_has(HttpReq *req, const char *needle)
357{
358 return strstr((const char *)req->body, needle) != nullptr;
359}
360
361// Extract the token from a Lock-Token Coded-URL ("<opaquelocktoken:...>") into @p out; false if malformed.
362static bool dav_coded_url_token(const char *coded, char *out, size_t cap)
363{
364 const char *lt = strchr(coded, '<');
365 if (!lt)
366 {
367 return false;
368 }
369 const char *gt = strchr(lt, '>');
370 if (!gt)
371 {
372 return false;
373 }
374 size_t n = (size_t)(gt - lt - 1);
375 if (n + 1 > cap)
376 {
377 return false;
378 }
379 memcpy(out, lt + 1, n);
380 out[n] = 0;
381 return true;
382}
383
384// GCOVR_EXCL_BR_START the null-stream_srv arm of all three trampolines is unreachable: the only
385// thing that installs them as the parser's stream hooks is dav(), which sets s_davput.stream_srv
386// first and never clears it - so a trampoline cannot run before the instance pointer exists.
387bool PC::dav_put_begin_tramp(HttpReq *req)
388{
389 return s_davput.stream_srv && s_davput.stream_srv->dav_stream_put_begin(req);
390}
391void PC::dav_put_data_tramp(HttpReq *req, const uint8_t *data, size_t len)
392{
393 if (s_davput.stream_srv)
394 {
395 s_davput.stream_srv->dav_stream_put_data(req, data, len);
396 }
397}
398// GCOVR_EXCL_BR_STOP
399void PC::dav_put_abort_tramp(HttpReq *req)
400{
401 // The PUT was torn down before the handler ran: close the half-written file so
402 // the handle is not leaked (a leak eventually exhausts LittleFS's open slots).
403 uint8_t slot = (uint8_t)(req - http_pool);
404 // GCOVR_EXCL_BR_START the slot >= MAX_CONNS half is unreachable: http_pool is CONN_POOL_SLOTS
405 // long, but the streaming-body hooks are driven only by the HTTP/1.x byte parser, which never
406 // parses for the internal dispatch slots at and above MAX_CONNS. s_davput.put[] is MAX_CONNS
407 // long, so the bound still has to be tested here.
408 if (slot < MAX_CONNS && s_davput.put[slot].active)
409 {
410 s_davput.put[slot].file.close();
411 s_davput.put[slot].active = false;
412 }
413 // GCOVR_EXCL_BR_STOP
414}
415
416bool PC::dav_stream_put_begin(HttpReq *req)
417{
418 if (strcmp(req->method, "PUT") != 0)
419 {
420 return false;
421 }
422 uint8_t slot = (uint8_t)(req - http_pool);
423 for (uint8_t i = 0; i < _route_count; i++)
424 {
425 Route *r = &_routes[i];
426 // The !is_active half cannot fire: every entry below _route_count was filled by
427 // fill_route_base, which sets is_active, and nothing ever clears it again.
428 if (!r->is_active || r->type != RouteType::ROUTE_DAV) // GCOVR_EXCL_BR_LINE see above
429 {
430 continue;
431 }
432 if (!path_matches(r->path, r->is_wildcard, req->path))
433 {
434 continue;
435 }
436 // GCOVR_EXCL_START dav() has no interface-filtered overload, so a ROUTE_DAV entry's
437 // iface_filter is always PC_IFACE_ANY and this gate never rejects. Kept so a DAV mount picks
438 // up the per-route interface gate for free if that overload is ever added.
439 if (r->iface_filter != pc_iface::PC_IFACE_ANY && r->iface_filter != pc_conn_iface(slot))
440 {
441 continue;
442 }
443 // GCOVR_EXCL_STOP
444 // GCOVR_EXCL_START a RouteType::ROUTE_DAV route always carries static_fs (set in dav()); this null-guard
445 // cannot fire
446 if (!r->static_fs)
447 {
448 return false;
449 }
450 // GCOVR_EXCL_STOP
451 char fs_path[256];
452 if (dav_resolve_path(r, req->path, fs_path, sizeof(fs_path)) != 0)
453 {
454 return false; // traversal / too long - let it buffer; the handler answers 403/414
455 }
456 DavPut *d = &s_davput.put[slot];
457 d->active = false;
458 d->error = false;
459 d->locked = false;
460 d->written = 0;
461 if (dav_write_blocked(req, req->path))
462 {
463 // Locked by another principal: consume the body but open no file, so the resource is not
464 // touched; the PUT handler answers 423 (RFC 4918 §7).
465 d->locked = true;
466 return true;
467 }
468 d->existed = r->static_fs->exists(fs_path);
469 d->file = r->static_fs->open(fs_path, "w");
470 if (d->file)
471 {
472 d->active = true;
473 }
474 else
475 {
476 d->error = true;
477 }
478 return true; // stream regardless so the body is consumed and the handler replies
479 }
480 return false;
481}
482
483void PC::dav_stream_put_data(HttpReq *req, const uint8_t *data, size_t len)
484{
485 uint8_t slot = (uint8_t)(req - http_pool);
486 // GCOVR_EXCL_START http_pool is CONN_POOL_SLOTS (MAX_CONNS + PC_INTERNAL_SLOTS) long, so an index >=
487 // MAX_CONNS is a real address - but it belongs to an internal dispatch slot (e.g. HTTP/3), and the
488 // streaming-body hooks this runs from are driven only by the HTTP/1.x byte parser, which never parses
489 // for those slots. s_davput.put[] is MAX_CONNS long, so the bound still has to be here.
490 if (slot >= MAX_CONNS)
491 {
492 return;
493 }
494 // GCOVR_EXCL_STOP
495 DavPut *d = &s_davput.put[slot];
496 if (d->active && !d->error)
497 {
498 if (d->file.write(data, len) != len)
499 {
500 d->error = true;
501 }
502 else
503 {
504 d->written += len;
505 }
506 }
507}
508#endif // PC_ENABLE_STREAM_BODY
509
510void PC::dav(const char *url_prefix, fs::FS &file_sys, const char *fs_root)
511{
512 if (_route_count >= MAX_ROUTES)
513 {
514 return;
515 }
516 Route *r = &_routes[_route_count++];
517
518 char pat[MAX_PATH_LEN];
519 size_t n = strnlen(url_prefix, MAX_PATH_LEN);
520 if (n > 0 && url_prefix[n - 1] == '*')
521 {
522 pc_sb sb_pat = {pat, sizeof(pat), 0, true};
523 pc_sb_put(&sb_pat, url_prefix);
524 if (pc_sb_finish(&sb_pat) == 0)
525 {
526 pat[0] = '\0';
527 }
528 }
529 else
530 {
531 pc_sb sb_pat2 = {pat, sizeof(pat), 0, true};
532 pc_sb_put(&sb_pat2, url_prefix);
533 pc_sb_put(&sb_pat2, "*");
534 if (pc_sb_finish(&sb_pat2) == 0)
535 {
536 pat[0] = '\0';
537 }
538 }
539 fill_route_base(r, pat);
540 r->type = RouteType::ROUTE_DAV;
541 r->method = HttpMethod::HTTP_GET; // unused: WebDAV dispatch keys off the raw method token
542 r->static_fs = &file_sys;
543 r->static_root = fs_root;
544
545#if PC_ENABLE_STREAM_BODY
546 // Stream PUT bodies straight to the file (one global sink; see PC_ENABLE_STREAM_BODY).
547 s_davput.stream_srv = this;
548 http_parser_set_stream_hooks(dav_put_begin_tramp, dav_put_data_tramp, dav_put_abort_tramp);
549#endif
550}
551
552void PC::dav_send_status(uint8_t slot_id, int code, const char *extra_headers)
553{
554 if (!pc_conn_active(slot_id))
555 {
556 http_reset(slot_id);
557 return;
558 }
559 bool keep;
560 const char *cl = pc_resp_conn_hdr(slot_id, &keep);
561 char header[RESP_HDR_BUF_SIZE];
562 // GCOVR_EXCL_BR_START the null arm of the extra_headers ternary is unreachable: every call site
563 // in this file passes either "" or a string literal. Kept so the parameter stays optional.
564 pc_sb sb_header = {header, sizeof(header), 0, true};
565 pc_sb_put(&sb_header, "HTTP/1.1 ");
566 pc_sb_i64(&sb_header, (int64_t)(code));
567 pc_sb_put(&sb_header, " ");
568 pc_sb_put(&sb_header, status_text(code));
569 pc_sb_put(&sb_header, "\r\n");
570 pc_sb_put(&sb_header, extra_headers ? extra_headers : "");
571 pc_sb_put(&sb_header, "Content-Length: 0\r\n");
572 pc_sb_put(&sb_header, cl);
573 pc_sb_put(&sb_header, "\r\n");
574 int hlen = (int)pc_sb_finish(&sb_header);
575 // GCOVR_EXCL_BR_STOP
576 pc_conn_send(slot_id, header, (u16_t)hlen);
577 pc_resp_end(slot_id, code, 0, keep);
578}
579
580bool PC::try_serve_dav(uint8_t slot_id, HttpReq *req)
581{
582 for (uint8_t i = 0; i < _route_count; i++)
583 {
584 Route *r = &_routes[i];
585 // The !is_active half cannot fire: every entry below _route_count was filled by
586 // fill_route_base, which sets is_active, and nothing ever clears it again.
587 if (!r->is_active || r->type != RouteType::ROUTE_DAV) // GCOVR_EXCL_BR_LINE see above
588 {
589 continue;
590 }
591 if (!path_matches(r->path, r->is_wildcard, req->path))
592 {
593 continue;
594 }
595 // GCOVR_EXCL_START dav() has no interface-filtered overload, so a ROUTE_DAV entry's
596 // iface_filter is always PC_IFACE_ANY and this gate never rejects. Kept so a DAV mount picks
597 // up the per-route interface gate for free if that overload is ever added.
598 if (r->iface_filter != pc_iface::PC_IFACE_ANY && r->iface_filter != pc_conn_iface(slot_id))
599 {
600 continue;
601 }
602 // GCOVR_EXCL_STOP
603 serve_dav_request(slot_id, req, r);
604 return true;
605 }
606 return false;
607}
608
609void PC::serve_dav_request(uint8_t slot_id, HttpReq *req, const Route *r)
610{
611 // GCOVR_EXCL_START a RouteType::ROUTE_DAV route always carries static_fs (set in dav()); this null-guard cannot
612 // fire
613 if (!r->static_fs)
614 {
615 dav_send_status(slot_id, 404, "");
616 return;
617 }
618 // GCOVR_EXCL_STOP
619 fs::FS &fsys = *r->static_fs;
620
621 char fs_path[256];
622 int rc = dav_resolve_path(r, req->path, fs_path, sizeof(fs_path));
623 if (rc != 0)
624 {
625 dav_send_status(slot_id, rc, ""); // 403 traversal / 414 too long
626 return;
627 }
628
629 // Mount-prefix length and FS root, used by COPY/MOVE to resolve the Destination. As in
630 // dav_resolve_path, plen == 0 is unreachable: dav() always stores at least "*".
631 size_t plen = strnlen(r->path, MAX_PATH_LEN);
632 if (plen > 0 && r->path[plen - 1] == '*') // GCOVR_EXCL_BR_LINE plen == 0 unreachable (see above)
633 {
634 plen--;
635 }
636 const char *root = r->static_root ? r->static_root : "";
637
638 // Expire any timed-out locks (RFC 4918 §6.6) before this request consults the table, so a stale lock
639 // never gates a write. The clock is pc_millis() (pluggable); seconds are enough for lock lifetimes.
640 uint32_t dav_now_s = (uint32_t)(pc_millis() / 1000u);
641 pc_dav_lock_sweep(&s_dav_lock.table, dav_now_s);
642
643 switch (pc_webdav_method(req->method))
644 {
645 case WebDavMethod::DAV_M_OPTIONS:
646 add_response_header(slot_id, "DAV", "1, 2");
647 add_response_header(slot_id, "Allow",
648 "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK");
649 add_response_header(slot_id, "MS-Author-Via", "DAV");
650 send_empty(slot_id, 200);
651 return;
652
653 case WebDavMethod::DAV_M_GET:
654 case WebDavMethod::DAV_M_HEAD: {
655 fs::File f = fsys.open(fs_path, "r");
656 if (!f)
657 {
658 dav_send_status(slot_id, 404, "");
659 return;
660 }
661 bool isdir = f.isDirectory();
662 f.close();
663 if (isdir)
664 {
665 dav_send_status(slot_id, 405, ""); // GET on a collection is not a download
666 return;
667 }
668 serve_file_internal(slot_id, pc_webdav_method(req->method) == WebDavMethod::DAV_M_HEAD, fsys, fs_path,
669 mime_type(fs_path), nullptr);
670 return;
671 }
672
673 case WebDavMethod::DAV_M_PUT: {
674#if PC_ENABLE_STREAM_BODY
675 if (req->body_streaming)
676 {
677 // The body was written to this slot's file as it arrived (dav_stream_put_*).
678 DavPut *d = &s_davput.put[slot_id];
679 if (d->locked)
680 {
681 d->locked = false;
682 dav_send_status(slot_id, 423, ""); // Locked: the body was consumed but nothing was written
683 return;
684 }
685 if (d->active)
686 {
687 d->file.close();
688 d->active = false; // closed here: the abort hook must not double-close
689 }
690 else
691 {
692 dav_send_status(slot_id, 409, ""); // parent missing / not writable
693 return;
694 }
695 if (d->error)
696 {
697 dav_send_status(slot_id, 507, ""); // a write failed (e.g. disk full)
698 return;
699 }
700 dav_send_status(slot_id, d->existed ? 204 : 201, "");
701 return;
702 }
703#endif
704 // Buffered fallback (streaming disabled): body bounded by BODY_BUF_SIZE.
705 if (dav_write_blocked(req, req->path))
706 {
707 dav_send_status(slot_id, 423, ""); // Locked: no / wrong lock token in the If header
708 return;
709 }
710 bool existed = fsys.exists(fs_path);
711 fs::File f = fsys.open(fs_path, "w");
712 if (!f)
713 {
714 dav_send_status(slot_id, 409, ""); // parent missing / not writable
715 return;
716 }
717 // GCOVR_EXCL_START only an empty CL:0 PUT reaches this buffered path, so body_len is always
718 // 0 and the write never runs: a bodied PUT to a DAV route always streams (dav() registers the
719 // sink, and the #error at the top of this file is what makes that hold - no other service can
720 // have taken the single global hook), and stream_begin's only decline reasons for a matched
721 // DAV route are the ones that also fail the top-level resolve above. Kept as a fail-safe so a
722 // caller that somehow does arrive buffered writes its body instead of silently dropping it.
723 if (req->body_len)
724 {
725 f.write(req->body, req->body_len);
726 }
727 // GCOVR_EXCL_STOP
728 f.close();
729 dav_send_status(slot_id, existed ? 204 : 201, "");
730 return;
731 }
732
733 case WebDavMethod::DAV_M_DELETE: {
734 if (dav_write_blocked(req, req->path))
735 {
736 dav_send_status(slot_id, 423, "");
737 return;
738 }
739 if (!fsys.exists(fs_path))
740 {
741 dav_send_status(slot_id, 404, "");
742 return;
743 }
744 dav_send_status(slot_id, dav_rm_recursive(fsys, fs_path, 0) ? 204 : 403, "");
745 return;
746 }
747
748 case WebDavMethod::DAV_M_MKCOL:
749 if (dav_write_blocked(req, req->path))
750 {
751 dav_send_status(slot_id, 423, "");
752 return;
753 }
754 if (fsys.exists(fs_path))
755 {
756 dav_send_status(slot_id, 405, ""); // already exists
757 return;
758 }
759 dav_send_status(slot_id, fsys.mkdir(fs_path) ? 201 : 409, "");
760 return;
761
762 case WebDavMethod::DAV_M_COPY:
763 case WebDavMethod::DAV_M_MOVE: {
764 const char *dest_hdr = http_get_header(req, "Destination");
765 char dest_url[256];
766 if (!dest_hdr || !pc_webdav_dest_path(dest_hdr, dest_url, sizeof(dest_url)))
767 {
768 dav_send_status(slot_id, 400, "");
769 return;
770 }
771 // The destination must live under this same mount.
772 if (strncmp(dest_url, r->path, plen) != 0)
773 {
774 dav_send_status(slot_id, 502, "");
775 return;
776 }
777 const char *dest_sub = dest_url + plen;
778 if (strstr(dest_sub, ".."))
779 {
780 dav_send_status(slot_id, 403, "");
781 return;
782 }
783 // Both COPY and MOVE write the destination; MOVE additionally removes the source. Each locked
784 // target needs the matching token in the If header (RFC 4918 §7).
785 bool is_move = pc_webdav_method(req->method) == WebDavMethod::DAV_M_MOVE;
786 if (dav_write_blocked(req, dest_url) || (is_move && dav_write_blocked(req, req->path)))
787 {
788 dav_send_status(slot_id, 423, "");
789 return;
790 }
791 char dest_fs[256];
792 if (!dav_join(root, dest_sub, dest_fs, sizeof(dest_fs)))
793 {
794 dav_send_status(slot_id, 414, "");
795 return;
796 }
797 size_t dpl = strnlen(dest_fs, sizeof(dest_fs));
798 if (dpl > 1 && dest_fs[dpl - 1] == '/')
799 {
800 dest_fs[dpl - 1] = '\0';
801 }
802
803 const char *ow = http_get_header(req, "Overwrite");
804 bool overwrite = !(ow && (ow[0] == 'F' || ow[0] == 'f'));
805 bool dest_exists = fsys.exists(dest_fs);
806 if (dest_exists && !overwrite)
807 {
808 dav_send_status(slot_id, 412, "");
809 return;
810 }
811
812 if (pc_webdav_method(req->method) == WebDavMethod::DAV_M_MOVE)
813 {
814 if (dest_exists)
815 {
816 dav_rm_recursive(fsys, dest_fs, 0); // replace
817 }
818 bool ok = fsys.rename(fs_path, dest_fs);
819 dav_send_status(slot_id, ok ? (dest_exists ? 204 : 201) : 409, "");
820 return;
821 }
822
823 // COPY: a file or a whole collection (RFC 4918 9.8). Depth applies to a
824 // collection source: "0" copies just the collection itself, "infinity"
825 // (the default, also when absent) copies the entire tree.
826 fs::File src = fsys.open(fs_path, "r");
827 if (!src)
828 {
829 dav_send_status(slot_id, 404, "");
830 return;
831 }
832 bool src_is_dir = src.isDirectory();
833 src.close();
834
835 const char *depth_h = http_get_header(req, "Depth");
836 bool shallow = depth_h && depth_h[0] == '0'; // Depth: 0
837
838 if (dest_exists)
839 {
840 dav_rm_recursive(fsys, dest_fs, 0); // overwrite: clear the target first
841 }
842
843 bool ok;
844 if (src_is_dir && shallow)
845 {
846 ok = fsys.mkdir(dest_fs); // collection, Depth:0 - just the collection, no members
847 }
848 else
849 {
850 ok = dav_copy_recursive(fsys, fs_path, dest_fs, 0);
851 }
852 dav_send_status(slot_id, ok ? (dest_exists ? 204 : 201) : 409, "");
853 return;
854 }
855
856 case WebDavMethod::DAV_M_LOCK: {
857 const uint32_t timeout_s = 3600; // the lock lifetime advertised in <D:timeout> below
858 uint32_t expiry_s = dav_now_s + timeout_s;
859
860 // A LOCK carrying the token in its If header is a refresh (RFC 4918 §9.10.2): extend the held
861 // lock's timeout rather than taking a new one.
862 const char *if_hdr = http_get_header(req, "If");
863 char iftok[PC_DAV_LOCK_TOKEN_MAX];
864 const DavLock *lk = nullptr;
865 if (if_hdr && pc_dav_if_token(if_hdr, iftok, sizeof(iftok)))
866 {
867 lk = pc_dav_lock_refresh(&s_dav_lock.table, iftok, expiry_s);
868 }
869
870 char token[PC_DAV_LOCK_TOKEN_MAX];
871 bool shared, depth_inf;
872 if (lk) // refreshed an existing lock: echo its stored scope / depth / token
873 {
874 pc_sb sb_token = {token, sizeof(token), 0, true};
875 pc_sb_put(&sb_token, lk->token);
876 if (pc_sb_finish(&sb_token) == 0)
877 {
878 token[0] = '\0';
879 }
880 shared = !lk->exclusive;
881 depth_inf = lk->depth_infinity;
882 }
883 else
884 {
885 // New lock: a lockinfo body naming <shared> is a shared lock (else exclusive); a LOCK defaults
886 // to Depth: infinity when the header is absent (RFC 4918 §9.10.3).
887 shared = req->body_len && dav_body_has(req, "shared");
888 depth_inf = pc_webdav_depth(http_get_header(req, "Depth"), PC_DAV_DEPTH_INFINITY) != 0;
889 unsigned long tok = (unsigned long)millis();
890#ifdef ARDUINO
891 tok ^= (unsigned long)esp_random();
892#endif
893 pc_sb sb_token2 = {token, sizeof(token), 0, true};
894 pc_sb_put(&sb_token2, "opaquelocktoken:");
895 pc_sb_hex(&sb_token2, (uint64_t)(tok), 8);
896 pc_sb_put(&sb_token2, "-pc");
897 if (pc_sb_finish(&sb_token2) == 0)
898 {
899 token[0] = '\0';
900 }
901 if (!pc_dav_lock_acquire(&s_dav_lock.table, req->path, token, /*exclusive=*/!shared, depth_inf, expiry_s))
902 {
903 dav_send_status(slot_id, 423, ""); // a conflicting lock already holds this resource / subtree
904 return;
905 }
906 }
907 pc_sb sb_buf = {s_dav.buf, sizeof(s_dav.buf), 0, true};
908 pc_sb_put(
909 &sb_buf,
910 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:prop "
911 "xmlns:D=\"DAV:\"><D:lockdiscovery><D:activelock><D:locktype><D:write/></D:locktype><D:lockscope><D:");
912 pc_sb_put(&sb_buf, shared ? "shared" : "exclusive");
913 pc_sb_put(&sb_buf, "/></D:lockscope><D:depth>");
914 pc_sb_put(&sb_buf, depth_inf ? "infinity" : "0");
915 pc_sb_put(&sb_buf, "</D:depth><D:timeout>Second-");
916 pc_sb_u32(&sb_buf, (uint32_t)((unsigned long)timeout_s));
917 pc_sb_put(&sb_buf, "</D:timeout><D:locktoken><D:href>");
918 pc_sb_put(&sb_buf, token);
919 pc_sb_put(&sb_buf, "</D:href></D:locktoken></D:activelock></D:lockdiscovery></D:prop>\n");
920 if (pc_sb_finish(&sb_buf) == 0)
921 {
922 s_dav.buf[0] = '\0';
923 }
924 // RFC 4918 §10.5: Lock-Token uses a Coded-URL (angle-bracketed).
925 char lt[64];
926 pc_sb sb_lt = {lt, sizeof(lt), 0, true};
927 pc_sb_put(&sb_lt, "<");
928 pc_sb_put(&sb_lt, token);
929 pc_sb_put(&sb_lt, ">");
930 if (pc_sb_finish(&sb_lt) == 0)
931 {
932 lt[0] = '\0';
933 }
934 add_response_header(slot_id, "Lock-Token", lt);
935 send(slot_id, 200, "application/xml; charset=utf-8", s_dav.buf);
936 return;
937 }
938
939 case WebDavMethod::DAV_M_UNLOCK: {
940 // Release the lock named by the Lock-Token header (a Coded-URL: "<opaquelocktoken:...>").
941 const char *lt = http_get_header(req, "Lock-Token");
942 char token[PC_DAV_LOCK_TOKEN_MAX];
943 if (!lt || !dav_coded_url_token(lt, token, sizeof(token)) || !pc_dav_lock_release(&s_dav_lock.table, token))
944 {
945 dav_send_status(slot_id, 409, ""); // no such lock to release (RFC 4918 §9.11.1)
946 return;
947 }
948 dav_send_status(slot_id, 204, "");
949 return;
950 }
951
952 case WebDavMethod::DAV_M_PROPFIND: {
953 fs::File f = fsys.open(fs_path, "r");
954 if (!f)
955 {
956 dav_send_status(slot_id, 404, "");
957 return;
958 }
959 bool isdir = f.isDirectory();
960 uint32_t fsize = (uint32_t)f.size();
961 time_t mtime = f.getLastWrite();
962
963 int depth = pc_webdav_depth(http_get_header(req, "Depth"), 1);
964
965 // RFC 4918 9.1.1: this server lists at most one level, so a Depth: infinity
966 // PROPFIND is rejected with 403 + the propfind-finite-depth precondition rather
967 // than silently returning a partial (one-level) 207 the client would read as
968 // complete. Clients wanting a listing use Depth: 0 or 1.
969 if (depth == PC_DAV_DEPTH_INFINITY)
970 {
971 f.close();
972 static const char body[] = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n"
973 "<D:error xmlns:D=\"DAV:\"><D:propfind-finite-depth/></D:error>\r\n";
974 send(slot_id, 403, "application/xml", body);
975 return;
976 }
977
978 // Self href: the request path, with a trailing '/' for a collection.
979 char self_href[MAX_PATH_LEN + 2];
980 pc_sb sb_self_href = {self_href, sizeof(self_href), 0, true};
981 pc_sb_put(&sb_self_href, req->path);
982 if (pc_sb_finish(&sb_self_href) == 0)
983 {
984 self_href[0] = '\0';
985 }
986 size_t sl = strnlen(self_href, sizeof(self_href));
987 // GCOVR_EXCL_BR_START two halves here cannot fire, both for the same reason: req->path is
988 // HttpReq::path[MAX_PATH_LEN] and the parser always leaves at least "/" in it, so sl is
989 // between 1 and MAX_PATH_LEN-1. That makes `sl == 0` impossible, and makes the room test
990 // below always true (self_href is MAX_PATH_LEN+2). Both are kept as bounds on an index.
991 if (isdir && (sl == 0 || self_href[sl - 1] != '/'))
992 {
993 if (sl + 1 < sizeof(self_href))
994 {
995 self_href[sl++] = '/';
996 self_href[sl] = '\0';
997 }
998 }
999 // GCOVR_EXCL_BR_STOP
1000
1001 size_t cap = sizeof(s_dav.buf), len = 0;
1002 len = pc_webdav_ms_begin(s_dav.buf, cap, len);
1003 char mt[40];
1004 http_rfc1123(mtime, mt, sizeof(mt));
1005 len = pc_webdav_ms_entry(s_dav.buf, cap, len, self_href, isdir, fsize, mt, isdir ? "" : mime_type(fs_path));
1006
1007 if (isdir && depth >= 1)
1008 {
1009 int count = 0;
1010 for (;;)
1011 {
1012 fs::File c = f.openNextFile();
1013 if (!c)
1014 {
1015 break;
1016 }
1017 // GCOVR_EXCL_START the buffer-full break below always preempts this cap: the
1018 // static_assert at the top of this file pins PC_WEBDAV_BUF_SIZE small enough that
1019 // s_dav.buf cannot hold PC_WEBDAV_MAX_ENTRIES entries. Kept so the count is bounded
1020 // whatever those two knobs are set to.
1021 if (count >= PC_WEBDAV_MAX_ENTRIES)
1022 {
1023 c.close();
1024 break;
1025 }
1026 // GCOVR_EXCL_STOP
1027 const char *base = dav_basename(c.name());
1028 bool cdir = c.isDirectory();
1029 uint32_t csize = (uint32_t)c.size();
1030 time_t cmt = c.getLastWrite();
1031 char chref[MAX_PATH_LEN + 80];
1032 pc_sb sb_chref = {chref, sizeof(chref), 0, true};
1033 pc_sb_put(&sb_chref, self_href);
1034 pc_sb_put(&sb_chref, base);
1035 pc_sb_put(&sb_chref, cdir ? "/" : "");
1036 if (pc_sb_finish(&sb_chref) == 0)
1037 {
1038 chref[0] = '\0';
1039 }
1040 char cmtbuf[40];
1041 http_rfc1123(cmt, cmtbuf, sizeof(cmtbuf));
1042 c.close();
1043 size_t before = len;
1044 len = pc_webdav_ms_entry(s_dav.buf, cap, len, chref, cdir, csize, cmtbuf, cdir ? "" : mime_type(base));
1045 if (len == before)
1046 {
1047 break; // buffer full - stop listing
1048 }
1049 count++;
1050 }
1051 }
1052 f.close();
1053 len = pc_webdav_ms_end(s_dav.buf, cap, len);
1054 send(slot_id, 207, "application/xml; charset=utf-8", s_dav.buf);
1055 return;
1056 }
1057
1058 case WebDavMethod::DAV_M_PROPPATCH: {
1059 // Read-only properties (no dead-property store): answer 207 with each
1060 // requested property refused 403, rather than 405 - keeps Explorer/Finder,
1061 // which PROPPATCH a timestamp right after a PUT, from erroring.
1062 if (!fsys.exists(fs_path))
1063 {
1064 dav_send_status(slot_id, 404, "");
1065 return;
1066 }
1067 size_t n =
1068 pc_webdav_proppatch_ms(s_dav.buf, sizeof(s_dav.buf), req->path, (const char *)req->body, req->body_len);
1069 // GCOVR_EXCL_START the builder cannot run out of room at this env's PC_WEBDAV_BUF_SIZE: its
1070 // output is the ~120-byte prologue, the escaped href (capped at 256 by the builder's own esc
1071 // buffer), at most PC_WEBDAV_MAX_PROPS echoed tags whose bytes all come out of req->body
1072 // (capped at BODY_BUF_SIZE), and the ~110-byte epilogue - about 1.2 KB against a 2 KB buffer.
1073 // It IS reachable at the 256-byte floor protocore_config.h enforces, so the guard stays.
1074 if (!n)
1075 {
1076 dav_send_status(slot_id, 507, ""); // Insufficient Storage: response did not fit the buffer
1077 return;
1078 }
1079 // GCOVR_EXCL_STOP
1080 send(slot_id, 207, "application/xml; charset=utf-8", s_dav.buf);
1081 return;
1082 }
1083
1084 case WebDavMethod::DAV_M_UNSUPPORTED:
1085 default:
1086 dav_send_status(
1087 slot_id, 405,
1088 "Allow: OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK\r\n");
1089 return;
1090 }
1091}
1092#endif // PC_ENABLE_WEBDAV
#define MAX_CONNS
Definition c2_defaults.h:47
#define MAX_ROUTES
Definition c2_defaults.h:61
Single-port HTTP server with deterministic, zero-allocation execution.
Definition protocore.h:348
static const char * mime_type(const char *path)
Guess a Content-Type from a path's file extension.
Definition response.cpp:431
void send_empty(uint8_t slot_id, int code)
Send a headers-only HTTP response and close the connection.
void add_response_header(uint8_t slot_id, const char *name, const char *value)
Queue a custom response header for the next send on this slot.
Definition response.cpp:368
void send(uint8_t slot_id, int code, const char *content_type, const char *payload)
Send an HTTP response with a body and close the connection.
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
HttpReq http_pool[CONN_POOL_SLOTS]
Pool of parser contexts, one per connection-pool slot (incl. reserved dispatch slots).
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
void fill_route_base(Route *r, const char *path)
Register a route in the route table.
const char * status_text(int code)
Convert an HTTP status code to its standard reason phrase.
Layer 7 (Application) - public HTTP routing API.
@ HTTP_GET
Safe, idempotent read.
#define RESP_HDR_BUF_SIZE
Stack buffer for HTTP response header lines in send() / send_empty() / send_unauth() / serve_file().
#define PC_WEBDAV_BUF_SIZE
Buffer (BSS) for a WebDAV 207 Multi-Status response, in bytes (see PC_ENABLE_WEBDAV).
#define FILE_CHUNK_SIZE
Bytes read from the filesystem and passed to tcp_write() per loop().
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
#define PC_WEBDAV_MAX_ENTRIES
Maximum children listed in a WebDAV Depth-1 PROPFIND (bounds the response).
@ PC_IFACE_ANY
Unknown / no filter (matches any interface).
Library-private declarations shared between protocore.cpp and the src/server/*.cpp request-handler tr...
void http_rfc1123(time_t t, char *out, size_t cap)
Format t as an RFC 1123 GMT date into out (cap bytes); out is emptied for t <= 0.
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_hex(pc_sb *b, uint64_t v, unsigned min_digits)
Append v as lowercase hex, zero-padded to at least min_digits (printf "%0Nx").
Definition strbuf.h:297
void pc_sb_i64(pc_sb *b, int64_t v)
Append v as signed decimal (64-bit), with a leading '-' when negative.
Definition strbuf.h:315
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
void pc_sb_u32(pc_sb *b, uint32_t v)
Append v as decimal (no leading zeros; "0" for zero).
Definition strbuf.h:303
Fully-parsed HTTP/1.1 request.
uint8_t body[BODY_BUF_SIZE+1]
Stored body bytes, always null-terminated.
size_t body_len
Bytes stored in body[] (≤ BODY_BUF_SIZE).
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
char method[PC_METHOD_BUF_SIZE]
HTTP method, null-terminated (OPTIONS, or WebDAV methods when enabled).
Internal route entry stored in the routing table.
Definition protocore.h:244
RouteType type
HTTP, WS, or SSE.
Definition protocore.h:246
pc_iface iface_filter
Interface gate; pc_iface::PC_IFACE_ANY (0) = match any interface.
Definition protocore.h:277
HttpMethod method
HTTP method (RouteType::ROUTE_HTTP only).
Definition protocore.h:247
bool is_active
false for unused table slots.
Definition protocore.h:273
bool is_wildcard
true when path ends with *.
Definition protocore.h:274
char path[MAX_PATH_LEN]
Null-terminated path pattern.
Definition protocore.h:245
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:500
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.
WebDAV server core (RFC 4918): method classification, header parsing, and the 207 Multi-Status XML bu...