DeterministicESPAsyncWebServer v6.28.0
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
edge_cache.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 edge_cache.cpp
6 * @brief CDN edge-cache tier - pure engine. See edge_cache.h.
7 */
8
10
11#if DETWS_ENABLE_EDGE_CACHE
12
14#include <string.h>
15
16namespace
17{
18char lc(char c)
19{
20 return (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c;
21}
22
23// Read a run of decimal digits at *pp into @p out; advance *pp. False if no digit is present.
24bool rd_uint(const char **pp, int *out)
25{
26 const char *p = *pp;
27 if (*p < '0' || *p > '9')
28 return false;
29 int v = 0;
30 while (*p >= '0' && *p <= '9')
31 {
32 v = v * 10 + (*p - '0');
33 p++;
34 }
35 *pp = p;
36 *out = v;
37 return true;
38}
39
40// Read a 3-letter month abbreviation at *pp -> 1..12; advance past it. False if unrecognized.
41bool rd_month(const char **pp, int *out)
42{
43 static const char MONTHS[] = "janfebmaraprmayjunjulaugsepoctnovdec";
44 const char *p = *pp;
45 char a = lc(p[0]);
46 char b = lc(p[1]);
47 char c = lc(p[2]);
48 for (int m = 0; m < 12; m++)
49 if (a == MONTHS[m * 3] && b == MONTHS[m * 3 + 1] && c == MONTHS[m * 3 + 2])
50 {
51 *pp = p + 3;
52 *out = m + 1;
53 return true;
54 }
55 return false;
56}
57
58// Read "hh:mm:ss" at *pp; advance past it.
59bool rd_time(const char **pp, int *hh, int *mm, int *ss)
60{
61 const char *p = *pp;
62 if (!rd_uint(&p, hh) || *p != ':')
63 return false;
64 p++;
65 if (!rd_uint(&p, mm) || *p != ':')
66 return false;
67 p++;
68 if (!rd_uint(&p, ss))
69 return false;
70 *pp = p;
71 return true;
72}
73
74// Days since 1970-01-01 for a proleptic-Gregorian y-m-d (Howard Hinnant's civil algorithm).
75int64_t days_from_civil(int y, int m, int d)
76{
77 y -= (m <= 2);
78 int64_t era = (y >= 0 ? y : y - 399) / 400;
79 int64_t yoe = y - era * 400; // [0, 399]
80 int64_t doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; // [0, 365]
81 int64_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
82 return era * 146097 + doe - 719468;
83}
84
85// Append @p s to out[*pos..cap), optionally lowercased. False (and no write) on overflow.
86bool k_append(char *out, size_t *pos, size_t cap, const char *s, bool lower)
87{
88 size_t p = *pos;
89 for (const char *q = s; *q; q++)
90 {
91 if (p + 1 >= cap) // leave room for the terminating NUL
92 return false;
93 out[p++] = lower ? lc(*q) : *q;
94 }
95 *pos = p;
96 return true;
97}
98} // namespace
99
100bool edge_header_value(const char *hdrs, size_t len, const char *name, char *out, size_t out_cap)
101{
102 if (!hdrs || !name || !out || out_cap == 0)
103 return false;
104 out[0] = '\0';
105 size_t namelen = strlen(name);
106 const char *p = hdrs;
107 const char *end = hdrs + len;
108 // Skip the status line.
109 while (p < end && *p != '\n')
110 p++;
111 if (p < end)
112 p++;
113 while (p < end)
114 {
115 if (*p == '\r' || *p == '\n') // blank line: end of the header block
116 break;
117 const char *le = p;
118 while (le < end && *le != '\n')
119 le++;
120 const char *lend = le; // exclusive; drop a trailing CR
121 if (lend > p && lend[-1] == '\r')
122 lend--;
123 const char *colon = p;
124 while (colon < lend && *colon != ':')
125 colon++;
126 if (colon < lend && (size_t)(colon - p) == namelen)
127 {
128 bool match = true;
129 for (size_t i = 0; i < namelen; i++)
130 if (lc(p[i]) != lc(name[i]))
131 {
132 match = false;
133 break;
134 }
135 if (match)
136 {
137 const char *v = colon + 1;
138 while (v < lend && (*v == ' ' || *v == '\t'))
139 v++;
140 const char *ve = lend;
141 while (ve > v && (ve[-1] == ' ' || ve[-1] == '\t'))
142 ve--;
143 size_t vl = (size_t)(ve - v);
144 if (vl >= out_cap)
145 return false; // never truncate a validator
146 for (size_t i = 0; i < vl; i++)
147 out[i] = v[i];
148 out[vl] = '\0';
149 return true;
150 }
151 }
152 p = (le < end) ? le + 1 : end;
153 }
154 return false;
155}
156
157int64_t edge_parse_http_date(const char *s, size_t len)
158{
159 if (!s)
160 return -1;
161 char buf[64];
162 while (len && (*s == ' ' || *s == '\t')) // trim leading OWS
163 {
164 s++;
165 len--;
166 }
167 if (len == 0 || len >= sizeof(buf))
168 return -1;
169 memcpy(buf, s, len);
170 buf[len] = '\0';
171
172 int mday = 0;
173 int mon = 0;
174 int year = 0;
175 int hh = 0;
176 int mm = 0;
177 int ss = 0;
178 const char *comma = strchr(buf, ',');
179 if (comma)
180 {
181 // IMF-fixdate "Sun, 06 Nov 1994 08:49:37 GMT" or RFC 850 "Sunday, 06-Nov-94 08:49:37 GMT".
182 const char *p = comma + 1;
183 while (*p == ' ')
184 p++;
185 if (!rd_uint(&p, &mday))
186 return -1;
187 bool rfc850 = (*p == '-');
188 if (*p == '-' || *p == ' ')
189 p++;
190 else
191 return -1;
192 if (!rd_month(&p, &mon))
193 return -1;
194 if (*p == '-')
195 p++;
196 else
197 while (*p == ' ')
198 p++;
199 if (!rd_uint(&p, &year))
200 return -1;
201 if (rfc850 && year < 100) // 2-digit year window (RFC 6265-style)
202 year += (year < 70) ? 2000 : 1900;
203 while (*p == ' ')
204 p++;
205 if (!rd_time(&p, &hh, &mm, &ss))
206 return -1;
207 }
208 else
209 {
210 // asctime "Sun Nov 6 08:49:37 1994".
211 const char *p = buf;
212 while (*p && *p != ' ')
213 p++;
214 while (*p == ' ')
215 p++;
216 if (!rd_month(&p, &mon))
217 return -1;
218 while (*p == ' ')
219 p++;
220 if (!rd_uint(&p, &mday))
221 return -1;
222 while (*p == ' ')
223 p++;
224 if (!rd_time(&p, &hh, &mm, &ss))
225 return -1;
226 while (*p == ' ')
227 p++;
228 if (!rd_uint(&p, &year))
229 return -1;
230 }
231
232 if (mon < 1 || mon > 12 || mday < 1 || mday > 31 || hh > 23 || mm > 59 || ss > 60)
233 return -1;
234 int64_t days = days_from_civil(year, mon, mday);
235 return days * 86400 + (int64_t)hh * 3600 + (int64_t)mm * 60 + ss;
236}
237
238long edge_freshness_lifetime(const DetwsCacheControl *cc, bool shared, int64_t date_epoch, int64_t expires_epoch)
239{
240 long expires_minus_date = -1;
241 if (date_epoch >= 0 && expires_epoch >= 0)
242 expires_minus_date = (long)(expires_epoch - date_epoch);
243 return cache_freshness_lifetime(cc, shared, expires_minus_date);
244}
245
246long edge_heuristic_lifetime(int64_t date_epoch, int64_t last_modified_epoch)
247{
248 if (date_epoch < 0 || last_modified_epoch < 0 || last_modified_epoch >= date_epoch)
249 return -1;
250 return (long)((date_epoch - last_modified_epoch) / 10); // RFC 9111 sec 4.2.2 (10%)
251}
252
253long edge_initial_age(int32_t age_hdr, int64_t date_epoch, int64_t response_time_epoch)
254{
255 long apparent = 0;
256 if (date_epoch >= 0 && response_time_epoch >= 0 && response_time_epoch > date_epoch)
257 apparent = (long)(response_time_epoch - date_epoch);
258 long corrected = (age_hdr > 0) ? (long)age_hdr : 0;
259 return (apparent > corrected) ? apparent : corrected;
260}
261
262long edge_current_age(long initial_age, uint32_t insert_ms, uint32_t now_ms)
263{
264 uint32_t resident_ms = now_ms - insert_ms; // unsigned: wrap-safe
265 return initial_age + (long)(resident_ms / 1000u);
266}
267
268bool edge_is_fresh_at(long lifetime, long current_age)
269{
270 return lifetime >= 0 && current_age < lifetime;
271}
272
273size_t edge_key_canon(const char *method, const char *host, const char *path, const char *query, bool include_query,
274 char *out, size_t out_cap)
275{
276 if (!method || !host || !path || !out || out_cap == 0)
277 return 0;
278 size_t pos = 0;
279 if (!k_append(out, &pos, out_cap, method, false))
280 return 0;
281 if (!k_append(out, &pos, out_cap, "\n", false) || !k_append(out, &pos, out_cap, host, true))
282 return 0;
283 if (!k_append(out, &pos, out_cap, "\n", false) || !k_append(out, &pos, out_cap, path, false))
284 return 0;
285 if (include_query && query && query[0])
286 {
287 if (!k_append(out, &pos, out_cap, "\n", false) || !k_append(out, &pos, out_cap, query, false))
288 return 0;
289 }
290 out[pos] = '\0';
291 return pos;
292}
293
294void edge_key_digest(const char *canon, size_t len, uint8_t digest[32])
295{
296 ssh_sha256((const uint8_t *)canon, len, digest);
297}
298
299bool edge_vary_serialize(const char *vary_header, EdgeHdrLookup lookup, void *ctx, char *out, size_t out_cap)
300{
301 if (!out || out_cap == 0)
302 return false;
303 out[0] = '\0';
304 if (!vary_header)
305 return true; // no Vary -> empty key
306 size_t pos = 0;
307 const char *p = vary_header;
308 while (*p)
309 {
310 while (*p == ' ' || *p == '\t' || *p == ',')
311 p++;
312 if (!*p)
313 break;
314 // one field-name token
315 char name[48];
316 size_t nl = 0;
317 while (*p && *p != ',' && *p != ' ' && *p != '\t')
318 {
319 if (*p == '*') // Vary: * -> uncacheable
320 return false;
321 if (nl + 1 < sizeof(name))
322 name[nl++] = lc(*p);
323 p++;
324 }
325 name[nl] = '\0';
326 if (nl == 0)
327 continue;
328 const char *val = lookup ? lookup(ctx, name) : nullptr;
329 // Emit "name\x1e value \x1f" so distinct names cannot alias and a present-but-empty value is
330 // distinguished from an absent one only by the name being recorded regardless.
331 if (!k_append(out, &pos, out_cap, name, false) || !k_append(out, &pos, out_cap, "\x1e", false))
332 return false;
333 if (val && !k_append(out, &pos, out_cap, val, false))
334 return false;
335 if (!k_append(out, &pos, out_cap, "\x1f", false))
336 return false;
337 }
338 out[pos] = '\0';
339 return true;
340}
341
342// --- L1 store ------------------------------------------------------------------------------------
343
344namespace
345{
346void lru_unlink(EdgeCacheStore *s, uint16_t i)
347{
348 EdgeEntry *e = &s->entries[i];
349 if (e->lru_prev != EDGE_LRU_NONE)
350 s->entries[e->lru_prev].lru_next = e->lru_next;
351 else
352 s->lru_head = e->lru_next;
353 if (e->lru_next != EDGE_LRU_NONE)
354 s->entries[e->lru_next].lru_prev = e->lru_prev;
355 else
356 s->lru_tail = e->lru_prev;
357 e->lru_prev = e->lru_next = EDGE_LRU_NONE;
358}
359
360void lru_push_front(EdgeCacheStore *s, uint16_t i)
361{
362 EdgeEntry *e = &s->entries[i];
363 e->lru_prev = EDGE_LRU_NONE;
364 e->lru_next = s->lru_head;
365 if (s->lru_head != EDGE_LRU_NONE)
366 s->entries[s->lru_head].lru_prev = i;
367 s->lru_head = i;
368 if (s->lru_tail == EDGE_LRU_NONE)
369 s->lru_tail = i;
370}
371
372void store_free(EdgeCacheStore *s, uint16_t i)
373{
374 lru_unlink(s, i);
375 s->entries[i].used = false;
376}
377
378// The path portion of a canonical key "METHOD\nhost\npath[\nquery]" (after the 2nd '\n'), or nullptr.
379const char *key_path(const char *key)
380{
381 int nl = 0;
382 for (const char *p = key; *p; p++)
383 if (*p == '\n' && ++nl == 2)
384 return p + 1;
385 return nullptr;
386}
387
388bool vary_is_star(const char *vary_header)
389{
390 if (!vary_header)
391 return false;
392 for (const char *p = vary_header; *p; p++)
393 if (*p == '*')
394 return true;
395 return false;
396}
397} // namespace
398
399void edge_store_init(EdgeCacheStore *s)
400{
401 memset(s, 0, sizeof(*s));
402 s->lru_head = s->lru_tail = EDGE_LRU_NONE;
403 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
404 s->entries[i].lru_prev = s->entries[i].lru_next = EDGE_LRU_NONE;
405}
406
407EdgeEntry *edge_store_alloc(EdgeCacheStore *s, const char *canon, const char *vary_key)
408{
409 size_t klen = strlen(canon);
410 if (klen >= sizeof(s->entries[0].key))
411 return nullptr; // key too long -> non-cacheable
412 uint16_t slot = EDGE_LRU_NONE;
413 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
414 if (!s->entries[i].used)
415 {
416 slot = i;
417 break;
418 }
419 if (slot == EDGE_LRU_NONE)
420 {
421 if (s->lru_tail == EDGE_LRU_NONE)
422 return nullptr; // DETWS_EDGE_CACHE_SLOTS == 0
423 slot = s->lru_tail;
424 // Offer the still-populated victim to the L2 write-back hook (skip transient passthrough slots).
425 if (s->on_evict && s->entries[slot].key[0] != '\0')
426 s->on_evict(s->evict_ctx, &s->entries[slot]);
427 store_free(s, slot);
428 s->stats.evictions++;
429 }
430 EdgeEntry *e = &s->entries[slot];
431 memset(e, 0, sizeof(*e));
432 e->lru_prev = e->lru_next = EDGE_LRU_NONE;
433 e->used = true;
434 memcpy(e->key, canon, klen);
435 e->key[klen] = '\0';
436 edge_key_digest(canon, klen, e->digest);
437 size_t vl = vary_key ? strlen(vary_key) : 0;
438 if (vl >= sizeof(e->vary_vals))
439 vl = sizeof(e->vary_vals) - 1;
440 if (vary_key)
441 memcpy(e->vary_vals, vary_key, vl);
442 e->vary_vals[vl] = '\0';
443 e->date_epoch = e->expires_epoch = -1;
444 lru_push_front(s, slot);
445 s->stats.stores++;
446 return e;
447}
448
449EdgeEntry *edge_store_lookup(EdgeCacheStore *s, const char *canon, const char *vary_key, uint32_t now_ms)
450{
451 const char *vk = vary_key ? vary_key : "";
452 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
453 {
454 EdgeEntry *e = &s->entries[i];
455 if (e->used && strcmp(e->key, canon) == 0 && strcmp(e->vary_vals, vk) == 0)
456 {
457 lru_unlink(s, i);
458 lru_push_front(s, i);
459 e->last_used_ms = now_ms;
460 return e;
461 }
462 }
463 return nullptr;
464}
465
466EdgeEntry *edge_store_find(EdgeCacheStore *s, const char *canon, EdgeHdrLookup lookup, void *ctx, uint32_t now_ms)
467{
468 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
469 {
470 EdgeEntry *e = &s->entries[i];
471 if (!e->used || strcmp(e->key, canon) != 0)
472 continue;
473 char cur[DETWS_EDGE_VARY_MAX];
474 // re-serialize the current request against this variant's Vary names (empty names -> "")
475 if (!edge_vary_serialize(e->vary_names, lookup, ctx, cur, sizeof(cur)))
476 continue;
477 if (strcmp(cur, e->vary_vals) == 0)
478 {
479 lru_unlink(s, i);
480 lru_push_front(s, i);
481 e->last_used_ms = now_ms;
482 return e;
483 }
484 }
485 return nullptr;
486}
487
488void edge_entry_set_freshness(EdgeEntry *e, const DetwsCacheControl *cc, bool shared, int64_t date_epoch,
489 int64_t expires_epoch, int64_t last_modified_epoch, int32_t age_hdr,
490 int64_t response_time_epoch, uint32_t now_ms)
491{
492 long lifetime = edge_freshness_lifetime(cc, shared, date_epoch, expires_epoch);
493 if (lifetime < 0)
494 lifetime = edge_heuristic_lifetime(date_epoch, last_modified_epoch);
495 if (lifetime < 0)
496 lifetime = DETWS_EDGE_DEFAULT_TTL_S;
497 e->lifetime_s = lifetime;
498 e->initial_age = edge_initial_age(age_hdr, date_epoch, response_time_epoch);
499 e->insert_ms = now_ms;
500 e->date_epoch = date_epoch;
501 e->expires_epoch = expires_epoch;
502 e->age_hdr = (age_hdr > 0) ? age_hdr : 0;
503}
504
505bool edge_entry_has_validator(const EdgeEntry *e)
506{
507 return e->etag[0] != '\0' || e->last_modified[0] != '\0';
508}
509
510bool edge_entry_fresh(const EdgeEntry *e, uint32_t now_ms)
511{
512 return edge_is_fresh_at(e->lifetime_s, edge_current_age(e->initial_age, e->insert_ms, now_ms));
513}
514
515uint32_t edge_store_sweep(EdgeCacheStore *s, uint32_t now_ms)
516{
517 uint32_t n = 0;
518 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
519 {
520 EdgeEntry *e = &s->entries[i];
521 if (e->used && !edge_entry_has_validator(e) && !edge_entry_fresh(e, now_ms))
522 {
523 store_free(s, i);
524 s->stats.evictions++;
525 n++;
526 }
527 }
528 return n;
529}
530
531uint32_t edge_store_purge(EdgeCacheStore *s, const char *canon)
532{
533 uint32_t n = 0;
534 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
535 if (s->entries[i].used && strcmp(s->entries[i].key, canon) == 0)
536 {
537 store_free(s, i);
538 s->stats.purges++;
539 n++;
540 }
541 return n;
542}
543
544uint32_t edge_store_purge_prefix(EdgeCacheStore *s, const char *prefix)
545{
546 size_t plen = strlen(prefix);
547 uint32_t n = 0;
548 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
549 {
550 if (!s->entries[i].used)
551 continue;
552 const char *path = key_path(s->entries[i].key);
553 if (path && strncmp(path, prefix, plen) == 0)
554 {
555 store_free(s, i);
556 s->stats.purges++;
557 n++;
558 }
559 }
560 return n;
561}
562
563void edge_store_free_entry(EdgeCacheStore *s, EdgeEntry *e)
564{
565 for (uint16_t i = 0; i < DETWS_EDGE_CACHE_SLOTS; i++)
566 if (&s->entries[i] == e)
567 {
568 store_free(s, i);
569 return;
570 }
571}
572
573bool edge_is_storeable(int status, const char *method, const DetwsCacheControl *cc, const char *vary_header,
574 size_t body_len)
575{
576 if (!method || strcmp(method, "GET") != 0)
577 return false;
578 if (status != 200)
579 return false; // v1: only 200 (other cacheable-by-default statuses are a follow-up)
580 if (cc && (cc->no_store || cc->cc_private))
581 return false;
582 if (vary_is_star(vary_header))
583 return false;
584 if (body_len > DETWS_EDGE_BODY_MAX)
585 return false;
586 return true;
587}
588
589// --- conditional revalidation --------------------------------------------------------------------
590
591namespace
592{
593// Append "<name>: <value>\r\n" to out[*pos..cap). False (and no write) on overflow.
594bool hdr_line(char *out, size_t *pos, size_t cap, const char *name, const char *value)
595{
596 return k_append(out, pos, cap, name, false) && k_append(out, pos, cap, ": ", false) &&
597 k_append(out, pos, cap, value, false) && k_append(out, pos, cap, "\r\n", false);
598}
599} // namespace
600
601size_t edge_build_conditional(const EdgeEntry *e, char *out, size_t cap)
602{
603 if (!out || cap == 0)
604 return 0;
605 size_t pos = 0;
606 if (e->etag[0] && !hdr_line(out, &pos, cap, "If-None-Match", e->etag))
607 return 0;
608 if (e->last_modified[0] && !hdr_line(out, &pos, cap, "If-Modified-Since", e->last_modified))
609 return 0;
610 out[pos] = '\0';
611 return pos;
612}
613
614void edge_apply_304(EdgeEntry *e, const char *new_hdrs, size_t hdr_len, int64_t response_time_epoch, uint32_t now_ms)
615{
616 char v[128];
617 DetwsCacheControl cc;
618 if (edge_header_value(new_hdrs, hdr_len, "Cache-Control", v, sizeof(v)))
619 cache_control_parse(v, strlen(v), &cc);
620 else
621 cache_control_init(&cc);
622
623 int64_t date = -1;
624 if (edge_header_value(new_hdrs, hdr_len, "Date", v, sizeof(v)))
625 date = edge_parse_http_date(v, strlen(v));
626 int64_t expires = -1;
627 if (edge_header_value(new_hdrs, hdr_len, "Expires", v, sizeof(v)))
628 expires = edge_parse_http_date(v, strlen(v));
629
630 int32_t age = 0;
631 if (edge_header_value(new_hdrs, hdr_len, "Age", v, sizeof(v)))
632 {
633 long a = 0;
634 bool any = false;
635 for (const char *p = v; *p >= '0' && *p <= '9'; p++)
636 {
637 a = a * 10 + (*p - '0');
638 any = true;
639 }
640 if (any)
641 age = (int32_t)a;
642 }
643
644 // Adopt any validators the 304 carried (RFC 9111 4.3.4: the newer representation metadata wins).
645 if (edge_header_value(new_hdrs, hdr_len, "ETag", v, sizeof(v)) && strlen(v) < sizeof(e->etag))
646 memcpy(e->etag, v, strlen(v) + 1);
647 int64_t last_mod = -1;
648 if (edge_header_value(new_hdrs, hdr_len, "Last-Modified", v, sizeof(v)))
649 {
650 last_mod = edge_parse_http_date(v, strlen(v));
651 if (strlen(v) < sizeof(e->last_modified))
652 memcpy(e->last_modified, v, strlen(v) + 1);
653 }
654 else if (e->last_modified[0])
655 last_mod = edge_parse_http_date(e->last_modified, strlen(e->last_modified));
656
657 edge_entry_set_freshness(e, &cc, true, date, expires, last_mod, age, response_time_epoch, now_ms);
658}
659
660#endif // DETWS_ENABLE_EDGE_CACHE
#define DETWS_EDGE_BODY_MAX
#define DETWS_EDGE_CACHE_SLOTS
#define DETWS_EDGE_VARY_MAX
#define DETWS_EDGE_DEFAULT_TTL_S
CDN edge-cache tier - pure engine (DETWS_ENABLE_EDGE_CACHE).
void ssh_sha256(const uint8_t *data, size_t len, uint8_t digest[SSH_SHA256_DIGEST_LEN])
One-shot SHA-256: hash len bytes and write the digest.
SHA-256 (FIPS 180-4) - streaming context and one-shot API.