Line data Source code
1 : #include "test_helpers.h"
2 : #include "mime_util.h"
3 : #include "raii.h"
4 : #include <string.h>
5 : #include <stdlib.h>
6 : #include <time.h>
7 : #include <unistd.h>
8 :
9 1 : void test_mime_util(void) {
10 :
11 : /* ── mime_get_header ───────────────────────────────────────────── */
12 :
13 : /* Basic header extraction */
14 1 : const char *msg1 =
15 : "From: Alice <alice@example.com>\r\n"
16 : "Subject: Hello World\r\n"
17 : "Date: Mon, 25 Mar 2026 10:00:00 +0000\r\n"
18 : "\r\n"
19 : "Body text here.";
20 :
21 : {
22 2 : RAII_STRING char *from = mime_get_header(msg1, "From");
23 1 : ASSERT(from != NULL, "mime_get_header: From should not be NULL");
24 1 : ASSERT(strcmp(from, "Alice <alice@example.com>") == 0, "From header value mismatch");
25 : }
26 :
27 : {
28 2 : RAII_STRING char *subject = mime_get_header(msg1, "Subject");
29 1 : ASSERT(subject != NULL, "mime_get_header: Subject should not be NULL");
30 1 : ASSERT(strcmp(subject, "Hello World") == 0, "Subject header value mismatch");
31 : }
32 :
33 : /* Header folding */
34 : {
35 1 : const char *folded =
36 : "Subject: This is a very\r\n"
37 : " long subject\r\n"
38 : "\r\n";
39 2 : RAII_STRING char *subj = mime_get_header(folded, "Subject");
40 1 : ASSERT(subj != NULL, "Folded subject should not be NULL");
41 1 : ASSERT(strcmp(subj, "This is a very long subject") == 0, "Folded subject mismatch");
42 : }
43 :
44 : /* Case-insensitive lookup */
45 : {
46 2 : RAII_STRING char *date = mime_get_header(msg1, "date");
47 1 : ASSERT(date != NULL, "mime_get_header: case-insensitive lookup should work");
48 : }
49 :
50 : /* Missing header returns NULL */
51 : {
52 2 : RAII_STRING char *cc = mime_get_header(msg1, "Cc");
53 1 : ASSERT(cc == NULL, "mime_get_header: missing header should return NULL");
54 : }
55 :
56 : /* NULL inputs */
57 1 : ASSERT(mime_get_header(NULL, "From") == NULL, "NULL msg should return NULL");
58 1 : ASSERT(mime_get_header(msg1, NULL) == NULL, "NULL name should return NULL");
59 :
60 : /* Headers stop at blank line — body should not be searched */
61 : {
62 1 : const char *msg2 =
63 : "Subject: Real\r\n"
64 : "\r\n"
65 : "Fake: Header\r\n";
66 2 : RAII_STRING char *fake = mime_get_header(msg2, "Fake");
67 1 : ASSERT(fake == NULL, "mime_get_header: should not find headers in body");
68 : }
69 :
70 : /* ── mime_get_text_body — plain text ───────────────────────────── */
71 :
72 : {
73 1 : const char *plain =
74 : "Content-Type: text/plain\r\n"
75 : "\r\n"
76 : "Simple body.";
77 2 : RAII_STRING char *body = mime_get_text_body(plain);
78 1 : ASSERT(body != NULL, "mime_get_text_body: plain should not be NULL");
79 1 : ASSERT(strstr(body, "Simple body.") != NULL, "Plain body content mismatch");
80 : }
81 :
82 : /* No Content-Type defaults to text/plain */
83 : {
84 1 : const char *no_ct =
85 : "Subject: test\r\n"
86 : "\r\n"
87 : "Hello!";
88 2 : RAII_STRING char *body = mime_get_text_body(no_ct);
89 1 : ASSERT(body != NULL, "mime_get_text_body: no Content-Type should default to plain");
90 1 : ASSERT(strstr(body, "Hello!") != NULL, "Default plain body content mismatch");
91 : }
92 :
93 : /* ── mime_get_text_body — base64 ───────────────────────────────── */
94 :
95 : /* "Hello, base64!" base64-encoded */
96 : {
97 1 : const char *b64msg =
98 : "Content-Type: text/plain\r\n"
99 : "Content-Transfer-Encoding: base64\r\n"
100 : "\r\n"
101 : "SGVsbG8sIGJhc2U2NCE=\r\n";
102 2 : RAII_STRING char *body = mime_get_text_body(b64msg);
103 1 : ASSERT(body != NULL, "mime_get_text_body: base64 should not be NULL");
104 1 : ASSERT(strstr(body, "Hello, base64!") != NULL, "Base64 decoded content mismatch");
105 : }
106 :
107 : /* ── mime_get_text_body — quoted-printable ─────────────────────── */
108 :
109 : {
110 1 : const char *qpmsg =
111 : "Content-Type: text/plain\r\n"
112 : "Content-Transfer-Encoding: quoted-printable\r\n"
113 : "\r\n"
114 : "Hello=2C=20QP=21\r\n"; /* "Hello, QP!" */
115 2 : RAII_STRING char *body = mime_get_text_body(qpmsg);
116 1 : ASSERT(body != NULL, "mime_get_text_body: QP should not be NULL");
117 1 : ASSERT(strstr(body, "Hello, QP!") != NULL, "QP decoded content mismatch");
118 : }
119 :
120 : /* ── mime_get_text_body — HTML fallback ────────────────────────── */
121 :
122 : {
123 1 : const char *html =
124 : "Content-Type: text/html\r\n"
125 : "\r\n"
126 : "<html><body><p>HTML body</p></body></html>";
127 2 : RAII_STRING char *body = mime_get_text_body(html);
128 1 : ASSERT(body != NULL, "mime_get_text_body: HTML fallback should not be NULL");
129 1 : ASSERT(strstr(body, "HTML body") != NULL, "HTML stripped content mismatch");
130 : }
131 :
132 : /* ── mime_get_text_body — multipart ────────────────────────────── */
133 :
134 : {
135 1 : const char *mp =
136 : "Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n"
137 : "\r\n"
138 : "--BOUND\r\n"
139 : "Content-Type: text/plain\r\n"
140 : "\r\n"
141 : "Multipart plain text\r\n"
142 : "--BOUND--\r\n";
143 2 : RAII_STRING char *body = mime_get_text_body(mp);
144 1 : ASSERT(body != NULL, "mime_get_text_body: multipart should not be NULL");
145 1 : ASSERT(strstr(body, "Multipart plain text") != NULL, "Multipart body content mismatch");
146 : }
147 :
148 : /* NULL input */
149 1 : ASSERT(mime_get_text_body(NULL) == NULL, "NULL msg should return NULL");
150 :
151 : /* ── mime_decode_words ──────────────────────────────────────────── */
152 :
153 : /* Plain string — no encoded words, returned verbatim */
154 : {
155 2 : RAII_STRING char *r = mime_decode_words("Hello World");
156 1 : ASSERT(r != NULL, "mime_decode_words: plain should not be NULL");
157 1 : ASSERT(strcmp(r, "Hello World") == 0, "plain string should be unchanged");
158 : }
159 :
160 : /* UTF-8 Q-encoding: Bí-Bor-Ász Kft. - Borászati Szaküzlet */
161 : {
162 2 : RAII_STRING char *r = mime_decode_words(
163 : "=?utf-8?Q?B=C3=AD-Bor-=C3=81sz_Kft=2E_-_Bor=C3=A1szati"
164 : "_Szak=C3=BCzlet?=");
165 1 : ASSERT(r != NULL, "mime_decode_words: Q UTF-8 should not be NULL");
166 1 : ASSERT(strcmp(r, "B\xc3\xad-Bor-\xc3\x81sz Kft. - Bor\xc3\xa1szati"
167 : " Szak\xc3\xbczlet") == 0,
168 : "Q UTF-8 decode mismatch");
169 : }
170 :
171 : /* UTF-8 B-encoding: "Hello" → base64 "SGVsbG8=" */
172 : {
173 2 : RAII_STRING char *r = mime_decode_words("=?utf-8?B?SGVsbG8=?=");
174 1 : ASSERT(r != NULL, "mime_decode_words: B UTF-8 should not be NULL");
175 1 : ASSERT(strcmp(r, "Hello") == 0, "B UTF-8 decode mismatch");
176 : }
177 :
178 : /* Multiple encoded words: whitespace between them must be stripped */
179 : {
180 2 : RAII_STRING char *r = mime_decode_words(
181 : "=?utf-8?Q?foo?= =?utf-8?Q?bar?=");
182 1 : ASSERT(r != NULL, "mime_decode_words: multi-word should not be NULL");
183 1 : ASSERT(strcmp(r, "foobar") == 0,
184 : "whitespace between encoded words should be stripped");
185 : }
186 :
187 : /* Mixed: encoded word followed by literal suffix */
188 : {
189 2 : RAII_STRING char *r = mime_decode_words(
190 : "=?utf-8?Q?Hello?= <user@example.com>");
191 1 : ASSERT(r != NULL, "mime_decode_words: mixed should not be NULL");
192 1 : ASSERT(strcmp(r, "Hello <user@example.com>") == 0,
193 : "mixed encoded + literal mismatch");
194 : }
195 :
196 : /* NULL input */
197 1 : ASSERT(mime_decode_words(NULL) == NULL,
198 : "mime_decode_words: NULL input should return NULL");
199 :
200 : /* ── mime_extract_imap_literal ──────────────────────────────────── */
201 :
202 : {
203 1 : const char *imap_resp =
204 : "* 1 FETCH (BODY[HEADER] {23}\r\n"
205 : "Subject: Test\r\n"
206 : "\r\n"
207 : ")\r\n"
208 : "A1 OK FETCH completed\r\n";
209 2 : RAII_STRING char *lit = mime_extract_imap_literal(imap_resp);
210 1 : ASSERT(lit != NULL, "mime_extract_imap_literal: should not be NULL");
211 1 : ASSERT(strncmp(lit, "Subject: Test", 13) == 0, "Literal content mismatch");
212 : }
213 :
214 : /* No literal in response */
215 : {
216 2 : RAII_STRING char *no_lit = mime_extract_imap_literal("* OK no literal here\r\n");
217 1 : ASSERT(no_lit == NULL, "No literal should return NULL");
218 : }
219 :
220 : /* NULL input */
221 1 : ASSERT(mime_extract_imap_literal(NULL) == NULL,
222 : "NULL response should return NULL");
223 :
224 : /* ── mime_format_date ───────────────────────────────────────────── */
225 :
226 : /* Force UTC so the expected output is timezone-independent. */
227 1 : const char *saved_tz = getenv("TZ");
228 1 : setenv("TZ", "UTC", 1);
229 1 : tzset();
230 :
231 : /* Standard RFC 2822 with weekday, UTC offset */
232 : {
233 2 : RAII_STRING char *r = mime_format_date("Tue, 10 Mar 2026 15:07:40 +0000");
234 1 : ASSERT(r != NULL, "mime_format_date: should not return NULL");
235 1 : ASSERT(strcmp(r, "2026-03-10 15:07") == 0, "UTC date format mismatch");
236 : }
237 :
238 : /* Date with +0100 offset: local (UTC) output should subtract 1 hour */
239 : {
240 2 : RAII_STRING char *r = mime_format_date("Thu, 26 Mar 2026 12:00:00 +0100");
241 1 : ASSERT(r != NULL, "mime_format_date: offset date should not return NULL");
242 1 : ASSERT(strcmp(r, "2026-03-26 11:00") == 0, "Offset date format mismatch");
243 : }
244 :
245 : /* Trailing timezone comment in parentheses */
246 : {
247 2 : RAII_STRING char *r = mime_format_date("Mon, 1 Jan 2026 00:00:00 +0000 (UTC)");
248 1 : ASSERT(r != NULL, "mime_format_date: comment date should not return NULL");
249 1 : ASSERT(strcmp(r, "2026-01-01 00:00") == 0, "Date with comment format mismatch");
250 : }
251 :
252 : /* Without day-of-week */
253 : {
254 2 : RAII_STRING char *r = mime_format_date("1 Jan 2026 10:30:00 +0000");
255 1 : ASSERT(r != NULL, "mime_format_date: no-weekday date should not return NULL");
256 1 : ASSERT(strcmp(r, "2026-01-01 10:30") == 0, "No-weekday date format mismatch");
257 : }
258 :
259 : /* Timezone name instead of numeric offset */
260 : {
261 2 : RAII_STRING char *r = mime_format_date("Tue, 24 Mar 2026 16:38:21 GMT");
262 1 : ASSERT(r != NULL, "mime_format_date: GMT date should not return NULL");
263 1 : ASSERT(strcmp(r, "2026-03-24 16:38") == 0, "GMT timezone date format mismatch");
264 : }
265 :
266 : /* Unparseable input: returns a copy of the raw string */
267 : {
268 2 : RAII_STRING char *r = mime_format_date("not a date");
269 1 : ASSERT(r != NULL, "mime_format_date: bad date should return raw copy");
270 1 : ASSERT(strcmp(r, "not a date") == 0, "Bad date should return raw input");
271 : }
272 :
273 : /* NULL input */
274 1 : ASSERT(mime_format_date(NULL) == NULL, "mime_format_date: NULL should return NULL");
275 :
276 : /* Restore original TZ */
277 1 : if (saved_tz)
278 0 : setenv("TZ", saved_tz, 1);
279 : else
280 1 : unsetenv("TZ");
281 1 : tzset();
282 :
283 : /* ── QP soft line break (=\r\n) ────────────────────────────────── */
284 :
285 : {
286 : /* "=" followed by \r\n is a soft break: the line ending is removed */
287 1 : const char *qp_soft =
288 : "Content-Type: text/plain\r\n"
289 : "Content-Transfer-Encoding: quoted-printable\r\n"
290 : "\r\n"
291 : "First=\r\n"
292 : "Second";
293 2 : RAII_STRING char *body = mime_get_text_body(qp_soft);
294 1 : ASSERT(body != NULL, "mime_get_text_body: QP soft break should not return NULL");
295 1 : ASSERT(strstr(body, "FirstSecond") != NULL,
296 : "QP soft line break should be removed");
297 : }
298 :
299 : /* ── body_start: LF-only separator ─────────────────────────────── */
300 :
301 : {
302 : /* Message with \n\n instead of \r\n\r\n as header/body separator */
303 1 : const char *lf_msg =
304 : "Content-Type: text/plain\n"
305 : "\n"
306 : "LF-only body";
307 2 : RAII_STRING char *body = mime_get_text_body(lf_msg);
308 1 : ASSERT(body != NULL, "mime_get_text_body: LF-only separator should work");
309 1 : ASSERT(strstr(body, "LF-only body") != NULL, "LF-only body content mismatch");
310 : }
311 :
312 : /* ── body_start: no separator → returns NULL ────────────────────── */
313 :
314 : {
315 : /* No blank line at all → body_start() returns NULL */
316 2 : RAII_STRING char *body = mime_get_text_body("Subject: no body");
317 1 : ASSERT(body == NULL,
318 : "mime_get_text_body: message without body separator should return NULL");
319 : }
320 :
321 : /* ── extract_charset: unquoted value ────────────────────────────── */
322 :
323 : {
324 1 : const char *ct_plain =
325 : "Content-Type: text/plain; charset=utf-8\r\n"
326 : "\r\n"
327 : "explicit UTF-8";
328 2 : RAII_STRING char *body = mime_get_text_body(ct_plain);
329 1 : ASSERT(body != NULL, "mime_get_text_body: unquoted charset=utf-8 should work");
330 1 : ASSERT(strstr(body, "explicit UTF-8") != NULL,
331 : "unquoted charset body mismatch");
332 : }
333 :
334 : /* ── extract_charset: quoted value ──────────────────────────────── */
335 :
336 : {
337 1 : const char *ct_quoted =
338 : "Content-Type: text/plain; charset=\"utf-8\"\r\n"
339 : "\r\n"
340 : "quoted charset";
341 2 : RAII_STRING char *body = mime_get_text_body(ct_quoted);
342 1 : ASSERT(body != NULL, "mime_get_text_body: quoted charset should work");
343 1 : ASSERT(strstr(body, "quoted charset") != NULL,
344 : "quoted charset body mismatch");
345 : }
346 :
347 : /* ── extract_charset: RFC 2045 allows LWSP around '=' ──────────── */
348 :
349 : {
350 : /* A real message from a webshop used charset = "iso-8859-2" with
351 : * spaces. extract_charset() matched the literal "charset=" only, so
352 : * the parameter went unnoticed, the iconv step was skipped silently,
353 : * and the raw bytes reached the terminal. Every spelling below must
354 : * decode identically. */
355 : static const char *headers[] = {
356 : "Content-Type: text/plain; charset=iso-8859-2\r\n",
357 : "Content-Type: text/plain; charset=\"iso-8859-2\"\r\n",
358 : "Content-Type: text/plain; charset = \"iso-8859-2\"\r\n",
359 : "Content-Type: text/plain; charset\t=\tiso-8859-2\r\n",
360 : };
361 : /* 0xE9 is 'é' in ISO-8859-2; in UTF-8 that is C3 A9. */
362 5 : for (size_t i = 0; i < sizeof(headers) / sizeof(headers[0]); i++) {
363 : char msg[256];
364 4 : snprintf(msg, sizeof(msg), "%s\r\nX\xe9Y", headers[i]);
365 8 : RAII_STRING char *body = mime_get_text_body(msg);
366 4 : ASSERT(body != NULL, "latin-2 charset spelling: body decoded");
367 4 : ASSERT(body && strstr(body, "X\xc3\xa9Y") != NULL,
368 : "latin-2 charset spelling: 0xE9 must become UTF-8 C3 A9");
369 : }
370 : }
371 :
372 : /* ── MimeTextInfo: charset handling must be visible to the caller ── */
373 :
374 : {
375 : /* Successful conversion: latin-2 in, UTF-8 out, no problem reported. */
376 : MimeTextInfo info;
377 2 : RAII_STRING char *body = mime_get_text_body_ex(
378 : "Content-Type: text/plain; charset=iso-8859-2\r\n\r\nX\xe9Y", &info);
379 1 : ASSERT(body != NULL, "text info: latin-2 body decoded");
380 1 : ASSERT(info.converted == 1, "text info: converted flag set");
381 1 : ASSERT(info.unknown_charset == 0, "text info: charset was known");
382 1 : ASSERT(info.invalid_sequence == 0, "text info: no invalid sequence");
383 1 : ASSERT(strcmp(info.declared_charset, "iso-8859-2") == 0,
384 : "text info: declared charset recorded");
385 : }
386 :
387 : {
388 : /* Unknown charset: iconv cannot open it, so the raw bytes pass through.
389 : * Without this flag the caller cannot tell that happened. */
390 : MimeTextInfo info;
391 2 : RAII_STRING char *body = mime_get_text_body_ex(
392 : "Content-Type: text/plain; charset=x-nonexistent-charset-42\r\n\r\nX\xe9Y",
393 : &info);
394 1 : ASSERT(body != NULL, "text info: unknown charset still returns text");
395 1 : ASSERT(info.unknown_charset == 1, "text info: unknown charset reported");
396 1 : ASSERT(info.converted == 0, "text info: nothing was converted");
397 1 : ASSERT(body && strstr(body, "X\xe9Y") != NULL,
398 : "text info: unknown charset passes bytes through unchanged");
399 : }
400 :
401 : {
402 : /* No charset parameter at all: nothing declared, nothing converted,
403 : * and no problem to report. */
404 : MimeTextInfo info;
405 2 : RAII_STRING char *body = mime_get_text_body_ex(
406 : "Content-Type: text/plain\r\n\r\nplain ascii", &info);
407 1 : ASSERT(body != NULL, "text info: body without charset decoded");
408 1 : ASSERT(info.declared_charset[0] == '\0',
409 : "text info: no charset declared");
410 1 : ASSERT(info.unknown_charset == 0 && info.invalid_sequence == 0,
411 : "text info: absent charset is not an error");
412 : }
413 :
414 : {
415 : /* The convenience wrapper must keep working with no info requested. */
416 2 : RAII_STRING char *body = mime_get_text_body(
417 : "Content-Type: text/plain; charset=iso-8859-2\r\n\r\nX\xe9Y");
418 1 : ASSERT(body != NULL && strstr(body, "X\xc3\xa9Y") != NULL,
419 : "text info: NULL info argument still decodes");
420 : }
421 :
422 : /* ── extract_charset: must not match a different parameter ──────── */
423 :
424 : {
425 : /* "x-charset" is not the charset parameter; treating it as one would
426 : * convert from the wrong encoding. */
427 1 : const char *ct_other =
428 : "Content-Type: text/plain; x-charset=iso-8859-2\r\n"
429 : "\r\n"
430 : "plain \xc3\xa9 text";
431 2 : RAII_STRING char *body = mime_get_text_body(ct_other);
432 1 : ASSERT(body != NULL, "x-charset: body returned");
433 1 : ASSERT(body && strstr(body, "\xc3\xa9") != NULL,
434 : "x-charset must not be treated as charset (bytes unchanged)");
435 : }
436 :
437 : /* ── extract_charset: empty quoted value → NULL (p == start) ────── */
438 :
439 : {
440 : /* charset="" → extract_charset returns NULL → charset_to_utf8 is
441 : * called with NULL charset and simply returns strdup(body). */
442 1 : const char *ct_empty =
443 : "Content-Type: text/plain; charset=\"\"\r\n"
444 : "\r\n"
445 : "empty charset";
446 2 : RAII_STRING char *body = mime_get_text_body(ct_empty);
447 1 : ASSERT(body != NULL, "mime_get_text_body: empty charset should not crash");
448 1 : ASSERT(strstr(body, "empty charset") != NULL,
449 : "empty charset body mismatch");
450 : }
451 :
452 : /* ── charset_to_utf8: ISO-8859-1 body via iconv ──────────────────── */
453 :
454 : {
455 : /* \xE9 = 'é' in ISO-8859-1; UTF-8 encoding: \xC3\xA9 */
456 1 : const char *iso_msg =
457 : "Content-Type: text/plain; charset=iso-8859-1\r\n"
458 : "\r\n"
459 : "\xE9t\xE9"; /* "été" in ISO-8859-1 */
460 2 : RAII_STRING char *body = mime_get_text_body(iso_msg);
461 1 : ASSERT(body != NULL,
462 : "mime_get_text_body: iso-8859-1 should not return NULL");
463 1 : ASSERT(strstr(body, "\xC3\xA9t\xC3\xA9") != NULL,
464 : "ISO-8859-1 to UTF-8 body conversion mismatch");
465 : }
466 :
467 : /* ── text_from_multipart: unquoted boundary ──────────────────────── */
468 :
469 : {
470 1 : const char *mp_unquoted =
471 : "Content-Type: multipart/mixed; boundary=NOBOUND\r\n"
472 : "\r\n"
473 : "--NOBOUND\r\n"
474 : "Content-Type: text/plain\r\n"
475 : "\r\n"
476 : "Unquoted boundary text\r\n"
477 : "--NOBOUND--\r\n";
478 2 : RAII_STRING char *body = mime_get_text_body(mp_unquoted);
479 1 : ASSERT(body != NULL,
480 : "mime_get_text_body: unquoted boundary should work");
481 1 : ASSERT(strstr(body, "Unquoted boundary text") != NULL,
482 : "Unquoted boundary multipart content mismatch");
483 : }
484 :
485 : /* ── text_from_multipart: two non-text parts → exercises loop-continue
486 : * path (lines 256-259) and closing-boundary break (line 257 final),
487 : * then returns NULL (line 261). ──────────────────────────────────── */
488 :
489 : {
490 : /* Both parts are application/octet-stream → text_from_part returns NULL
491 : * for each. After the first part the loop continues (lines 258-259),
492 : * then the second delimiter turns out to be the closing "--B3--" →
493 : * break → return NULL. */
494 1 : const char *mp_none =
495 : "Content-Type: multipart/mixed; boundary=B3\r\n"
496 : "\r\n"
497 : "--B3\r\n"
498 : "Content-Type: application/octet-stream\r\n"
499 : "\r\n"
500 : "binary1\r\n"
501 : "--B3\r\n"
502 : "Content-Type: application/octet-stream\r\n"
503 : "\r\n"
504 : "binary2\r\n"
505 : "--B3--\r\n";
506 2 : RAII_STRING char *body = mime_get_text_body(mp_none);
507 1 : ASSERT(body == NULL,
508 : "mime_get_text_body: all-binary multipart should return NULL");
509 : }
510 :
511 : /* ── mime_decode_words: ISO-8859-1 encoded word via iconv ─────────── */
512 :
513 : {
514 : /* "été": \xE9=é, \xE9=é in ISO-8859-1 Q-encoding */
515 2 : RAII_STRING char *r = mime_decode_words("=?iso-8859-1?Q?=E9t=E9?=");
516 1 : ASSERT(r != NULL,
517 : "mime_decode_words: iso-8859-1 word should not return NULL");
518 1 : ASSERT(strcmp(r, "\xC3\xA9t\xC3\xA9") == 0,
519 : "ISO-8859-1 Q-encoded word UTF-8 decode mismatch");
520 : }
521 :
522 : /* ── mime_decode_words: unknown charset → raw bytes fallback ─────── */
523 :
524 : {
525 : /* iconv_open fails for unknown charset: raw decoded bytes returned */
526 2 : RAII_STRING char *r = mime_decode_words("=?x-unknown-charset?Q?hello?=");
527 1 : ASSERT(r != NULL,
528 : "mime_decode_words: unknown charset should not return NULL");
529 1 : ASSERT(strcmp(r, "hello") == 0,
530 : "Unknown charset encoded word should pass through raw bytes");
531 : }
532 :
533 : /* ── mime_extract_imap_literal: content shorter than claimed size ── */
534 :
535 : {
536 : /* {100} claims 100 bytes but only 5 are present */
537 1 : const char *trunc = "* FETCH {100}\r\nhello";
538 2 : RAII_STRING char *lit = mime_extract_imap_literal(trunc);
539 1 : ASSERT(lit != NULL,
540 : "mime_extract_imap_literal: truncated should not return NULL");
541 1 : ASSERT(strcmp(lit, "hello") == 0,
542 : "Truncated literal should return all available bytes");
543 : }
544 :
545 : /* ── mime_get_header: long value triggers realloc (>512 bytes) ───── */
546 :
547 : {
548 : /* 580 Z's exceed the initial 512-byte buffer → realloc required */
549 : char big_msg[700];
550 1 : strcpy(big_msg, "X-Big: ");
551 1 : memset(big_msg + 7, 'Z', 580);
552 1 : strcpy(big_msg + 587, "\r\n\r\n");
553 2 : RAII_STRING char *val = mime_get_header(big_msg, "X-Big");
554 1 : ASSERT(val != NULL,
555 : "mime_get_header: 580-char value should not return NULL");
556 1 : ASSERT(strlen(val) == 580, "Long header value length mismatch");
557 : }
558 :
559 : /* ── mime_get_header: folded long value triggers realloc ─────────── */
560 :
561 : {
562 : /* 511 A's fill the buffer, then a folded continuation adds space+X.
563 : * When the fold handler tries to add the separator space, n+1==512==cap
564 : * → realloc is triggered inside the fold branch. */
565 : char fold_msg[700];
566 1 : strcpy(fold_msg, "X-Fold: "); /* 8 chars */
567 1 : memset(fold_msg + 8, 'A', 511); /* 511 A's */
568 1 : strcpy(fold_msg + 519, "\r\n X\r\n\r\n");
569 2 : RAII_STRING char *val = mime_get_header(fold_msg, "X-Fold");
570 1 : ASSERT(val != NULL,
571 : "mime_get_header: folded long value should not return NULL");
572 1 : ASSERT(strlen(val) == 513,
573 : "Folded long header value length mismatch (511 A + space + X)");
574 : }
575 :
576 : /* ── mime_get_html_part ─────────────────────────────────────────── */
577 :
578 : /* HTML-only message */
579 : {
580 1 : const char *html_msg =
581 : "Content-Type: text/html\r\n"
582 : "\r\n"
583 : "<html><body><b>Bold</b></body></html>";
584 2 : RAII_STRING char *html = mime_get_html_part(html_msg);
585 1 : ASSERT(html != NULL, "mime_get_html_part: html-only should not be NULL");
586 1 : ASSERT(strstr(html, "<b>Bold</b>") != NULL,
587 : "mime_get_html_part: html content present");
588 : }
589 :
590 : /* Plain-only message → NULL */
591 : {
592 1 : const char *plain_msg =
593 : "Content-Type: text/plain\r\n"
594 : "\r\n"
595 : "Plain only";
596 2 : RAII_STRING char *html = mime_get_html_part(plain_msg);
597 1 : ASSERT(html == NULL, "mime_get_html_part: plain-only should return NULL");
598 : }
599 :
600 : /* NULL input → NULL */
601 1 : ASSERT(mime_get_html_part(NULL) == NULL,
602 : "mime_get_html_part: NULL should return NULL");
603 :
604 : /* multipart/alternative with html part */
605 : {
606 1 : const char *alt_msg =
607 : "Content-Type: multipart/alternative; boundary=\"ALT\"\r\n"
608 : "\r\n"
609 : "--ALT\r\n"
610 : "Content-Type: text/plain\r\n"
611 : "\r\n"
612 : "Plain fallback\r\n"
613 : "--ALT\r\n"
614 : "Content-Type: text/html\r\n"
615 : "\r\n"
616 : "<p>HTML part</p>\r\n"
617 : "--ALT--\r\n";
618 2 : RAII_STRING char *html = mime_get_html_part(alt_msg);
619 1 : ASSERT(html != NULL, "mime_get_html_part: multipart/alt html should not be NULL");
620 1 : ASSERT(strstr(html, "<p>HTML part</p>") != NULL,
621 : "mime_get_html_part: multipart html content present");
622 : }
623 :
624 : /* multipart with unquoted boundary (covers html_from_multipart unquoted path) */
625 : {
626 1 : const char *unquoted_msg =
627 : "Content-Type: multipart/alternative; boundary=UNQUOTED\r\n"
628 : "\r\n"
629 : "--UNQUOTED\r\n"
630 : "Content-Type: text/html\r\n"
631 : "\r\n"
632 : "<b>unquoted</b>\r\n"
633 : "--UNQUOTED--\r\n";
634 2 : RAII_STRING char *html = mime_get_html_part(unquoted_msg);
635 1 : ASSERT(html != NULL, "mime_get_html_part: unquoted boundary not NULL");
636 1 : ASSERT(strstr(html, "<b>unquoted</b>") != NULL,
637 : "mime_get_html_part: unquoted boundary content present");
638 : }
639 :
640 : /* multipart with no HTML parts → NULL (covers html_from_multipart return NULL) */
641 : {
642 1 : const char *no_html_msg =
643 : "Content-Type: multipart/mixed; boundary=\"NOHTML\"\r\n"
644 : "\r\n"
645 : "--NOHTML\r\n"
646 : "Content-Type: text/plain\r\n"
647 : "\r\n"
648 : "plain only\r\n"
649 : "--NOHTML--\r\n";
650 2 : RAII_STRING char *html = mime_get_html_part(no_html_msg);
651 1 : ASSERT(html == NULL, "mime_get_html_part: no-html multipart should return NULL");
652 : }
653 :
654 : /* ── mime_list_attachments: single base64 attachment ─────────────── */
655 :
656 : {
657 : /* "dGVzdA==" is base64 for "test" */
658 1 : const char *mime =
659 : "MIME-Version: 1.0\r\n"
660 : "Content-Type: multipart/mixed; boundary=\"B001\"\r\n"
661 : "\r\n"
662 : "--B001\r\n"
663 : "Content-Type: text/plain\r\n"
664 : "\r\n"
665 : "Body\r\n"
666 : "--B001\r\n"
667 : "Content-Type: application/octet-stream; name=\"file.bin\"\r\n"
668 : "Content-Disposition: attachment; filename=\"file.bin\"\r\n"
669 : "Content-Transfer-Encoding: base64\r\n"
670 : "\r\n"
671 : "dGVzdA==\r\n"
672 : "--B001--\r\n";
673 :
674 1 : int count = 0;
675 1 : MimeAttachment *list = mime_list_attachments(mime, &count);
676 1 : ASSERT(list != NULL, "list_attachments: not NULL");
677 1 : ASSERT(count == 1, "list_attachments: count=1");
678 1 : if (list && count > 0) {
679 1 : ASSERT(strcmp(list[0].filename, "file.bin") == 0,
680 : "list_attachments: filename is file.bin");
681 1 : ASSERT(list[0].data != NULL, "list_attachments: data not NULL");
682 : }
683 1 : mime_free_attachments(list, count);
684 : }
685 :
686 : /* ── mime_list_attachments: NULL input ───────────────────────────── */
687 :
688 : {
689 1 : int count = -1;
690 1 : MimeAttachment *list = mime_list_attachments(NULL, &count);
691 1 : ASSERT(list == NULL, "list_attachments: NULL msg returns NULL");
692 1 : ASSERT(count == 0, "list_attachments: NULL msg sets count=0");
693 : }
694 :
695 : /* ── mime_list_attachments: plain text — no attachments ──────────── */
696 :
697 : {
698 1 : const char *plain = "Content-Type: text/plain\r\n\r\nHello";
699 1 : int count = 0;
700 1 : MimeAttachment *list = mime_list_attachments(plain, &count);
701 1 : ASSERT(count == 0, "no attachments: count=0");
702 1 : mime_free_attachments(list, count);
703 : }
704 :
705 : /* ── mime_list_attachments: quoted filename ───────────────────────── */
706 :
707 : {
708 1 : const char *mime_q =
709 : "MIME-Version: 1.0\r\n"
710 : "Content-Type: multipart/mixed; boundary=\"B002\"\r\n"
711 : "\r\n"
712 : "--B002\r\n"
713 : "Content-Type: application/pdf; name=\"quoted file.pdf\"\r\n"
714 : "Content-Disposition: attachment; filename=\"quoted file.pdf\"\r\n"
715 : "\r\n"
716 : "data\r\n"
717 : "--B002--\r\n";
718 1 : int count2 = 0;
719 1 : MimeAttachment *list2 = mime_list_attachments(mime_q, &count2);
720 1 : ASSERT(count2 == 1, "quoted filename: count=1");
721 1 : if (list2 && count2 > 0)
722 1 : ASSERT(strstr(list2[0].filename, "quoted") != NULL,
723 : "quoted filename: name contains 'quoted'");
724 1 : mime_free_attachments(list2, count2);
725 : }
726 :
727 : /* ── mime_list_attachments: multiple attachments ─────────────────── */
728 :
729 : {
730 1 : const char *mime2 =
731 : "Content-Type: multipart/mixed; boundary=\"B003\"\r\n"
732 : "\r\n"
733 : "--B003\r\n"
734 : "Content-Type: application/pdf; name=\"doc.pdf\"\r\n"
735 : "Content-Disposition: attachment; filename=\"doc.pdf\"\r\n"
736 : "\r\n"
737 : "pdfdata\r\n"
738 : "--B003\r\n"
739 : "Content-Type: image/png; name=\"img.png\"\r\n"
740 : "Content-Disposition: attachment; filename=\"img.png\"\r\n"
741 : "\r\n"
742 : "imgdata\r\n"
743 : "--B003--\r\n";
744 1 : int cnt = 0;
745 1 : MimeAttachment *ml = mime_list_attachments(mime2, &cnt);
746 1 : ASSERT(cnt == 2, "multi attachments: count=2");
747 1 : mime_free_attachments(ml, cnt);
748 : }
749 :
750 : /* ── mime_save_attachment: write decoded content to disk ─────────── */
751 :
752 : {
753 : /* base64 "dGVzdA==" = "test" (4 bytes) */
754 1 : const char *mime3 =
755 : "Content-Type: multipart/mixed; boundary=\"B004\"\r\n"
756 : "\r\n"
757 : "--B004\r\n"
758 : "Content-Type: application/octet-stream; name=\"save.bin\"\r\n"
759 : "Content-Disposition: attachment; filename=\"save.bin\"\r\n"
760 : "Content-Transfer-Encoding: base64\r\n"
761 : "\r\n"
762 : "dGVzdA==\r\n"
763 : "--B004--\r\n";
764 :
765 1 : int cnt3 = 0;
766 1 : MimeAttachment *list3 = mime_list_attachments(mime3, &cnt3);
767 1 : ASSERT(cnt3 == 1, "save_attachment: list count=1");
768 1 : if (list3 && cnt3 > 0) {
769 1 : char tmpdir[] = "/tmp/mime_test_XXXXXX";
770 1 : char *dir = mkdtemp(tmpdir);
771 1 : ASSERT(dir != NULL, "save_attachment: mkdtemp succeeded");
772 1 : if (dir) {
773 : char path[256];
774 1 : snprintf(path, sizeof(path), "%s/%s", dir, list3[0].filename);
775 1 : int saved = mime_save_attachment(&list3[0], path);
776 1 : ASSERT(saved == 0, "save_attachment: returns 0");
777 1 : ASSERT(access(path, F_OK) == 0, "save_attachment: file exists");
778 1 : unlink(path);
779 1 : rmdir(dir);
780 : }
781 : }
782 1 : mime_free_attachments(list3, cnt3);
783 : }
784 :
785 : /* ── mime_save_attachment: NULL inputs ───────────────────────────── */
786 :
787 : {
788 1 : int rc_null = mime_save_attachment(NULL, "/tmp/irrelevant");
789 1 : ASSERT(rc_null == -1, "save_attachment: NULL att returns -1");
790 : }
791 :
792 : /* ── mime_list_attachments: explicit attachment, no filename → auto-name */
793 :
794 : {
795 1 : const char *mime4 =
796 : "Content-Type: multipart/mixed; boundary=\"B005\"\r\n"
797 : "\r\n"
798 : "--B005\r\n"
799 : "Content-Type: application/octet-stream\r\n"
800 : "Content-Disposition: attachment\r\n"
801 : "\r\n"
802 : "binarydata\r\n"
803 : "--B005--\r\n";
804 1 : int cnt4 = 0;
805 1 : MimeAttachment *list4 = mime_list_attachments(mime4, &cnt4);
806 1 : ASSERT(cnt4 == 1, "auto-name attachment: count=1");
807 1 : if (list4 && cnt4 > 0)
808 1 : ASSERT(list4[0].filename != NULL, "auto-name attachment: filename not NULL");
809 1 : mime_free_attachments(list4, cnt4);
810 : }
811 :
812 : /* ── mime_list_attachments: unquoted boundary ─────────────────────── */
813 :
814 : {
815 1 : const char *mime5 =
816 : "Content-Type: multipart/mixed; boundary=UBND\r\n"
817 : "\r\n"
818 : "--UBND\r\n"
819 : "Content-Type: application/zip; name=\"archive.zip\"\r\n"
820 : "Content-Disposition: attachment; filename=\"archive.zip\"\r\n"
821 : "\r\n"
822 : "zipdata\r\n"
823 : "--UBND--\r\n";
824 1 : int cnt5 = 0;
825 1 : MimeAttachment *list5 = mime_list_attachments(mime5, &cnt5);
826 1 : ASSERT(cnt5 == 1, "unquoted boundary attachment: count=1");
827 1 : if (list5 && cnt5 > 0)
828 1 : ASSERT(strcmp(list5[0].filename, "archive.zip") == 0,
829 : "unquoted boundary attachment: filename correct");
830 1 : mime_free_attachments(list5, cnt5);
831 : }
832 :
833 : /* ── extract_param: unquoted value (lines 590-595) ───────────────── */
834 : /* A Content-Disposition with unquoted filename triggers the unquoted
835 : * extraction path in extract_param. */
836 : {
837 1 : const char *mime6 =
838 : "Content-Type: multipart/mixed; boundary=\"B006\"\r\n"
839 : "\r\n"
840 : "--B006\r\n"
841 : "Content-Type: application/octet-stream\r\n"
842 : "Content-Disposition: attachment; filename=unquoted.bin\r\n"
843 : "\r\n"
844 : "binarydata\r\n"
845 : "--B006--\r\n";
846 1 : int cnt6 = 0;
847 1 : MimeAttachment *list6 = mime_list_attachments(mime6, &cnt6);
848 1 : ASSERT(cnt6 == 1, "unquoted filename param: count=1");
849 1 : if (list6 && cnt6 > 0)
850 1 : ASSERT(strcmp(list6[0].filename, "unquoted.bin") == 0,
851 : "unquoted filename param: name correct");
852 1 : mime_free_attachments(list6, cnt6);
853 : }
854 :
855 : /* ── collect_parts: text/plain with name= but no attachment disp
856 : * → body part, skip (lines 715-718) ───────────────────────────── */
857 : {
858 : /* text/plain with a name parameter but Content-Disposition: inline
859 : * (not "attachment") → should be treated as a body part, not an
860 : * attachment. count must be 0. */
861 1 : const char *mime7 =
862 : "Content-Type: multipart/mixed; boundary=\"B007\"\r\n"
863 : "\r\n"
864 : "--B007\r\n"
865 : "Content-Type: text/plain; name=readme.txt\r\n"
866 : "Content-Disposition: inline\r\n"
867 : "\r\n"
868 : "inline text\r\n"
869 : "--B007--\r\n";
870 1 : int cnt7 = 0;
871 1 : MimeAttachment *list7 = mime_list_attachments(mime7, &cnt7);
872 1 : ASSERT(cnt7 == 0, "text/plain name= no attachment disp: count=0");
873 1 : mime_free_attachments(list7, cnt7);
874 : }
875 :
876 : /* ── collect_parts: text/html with name= but no attachment disp
877 : * → body part, skip (hits text/html branch of || on line 716) ─── */
878 : {
879 1 : const char *mime7b =
880 : "Content-Type: multipart/mixed; boundary=\"B007B\"\r\n"
881 : "\r\n"
882 : "--B007B\r\n"
883 : "Content-Type: text/html; name=page.html\r\n"
884 : "Content-Disposition: inline\r\n"
885 : "\r\n"
886 : "<p>inline html</p>\r\n"
887 : "--B007B--\r\n";
888 1 : int cnt7b = 0;
889 1 : MimeAttachment *list7b = mime_list_attachments(mime7b, &cnt7b);
890 1 : ASSERT(cnt7b == 0, "text/html name= no attachment disp: count=0");
891 1 : mime_free_attachments(list7b, cnt7b);
892 : }
893 :
894 : /* ── collect_parts: no body separator → early return (lines 724-725) */
895 : {
896 : /* An attachment part that has no blank line (no body separator) —
897 : * body_start() returns NULL → collect_parts returns without adding. */
898 1 : const char *mime8 =
899 : "Content-Type: multipart/mixed; boundary=\"B008\"\r\n"
900 : "\r\n"
901 : "--B008\r\n"
902 : "Content-Type: application/octet-stream\r\n"
903 : "Content-Disposition: attachment; filename=noseq.bin\r\n"
904 : "--B008--\r\n";
905 1 : int cnt8 = 0;
906 1 : MimeAttachment *list8 = mime_list_attachments(mime8, &cnt8);
907 1 : ASSERT(cnt8 == 0, "attachment no body separator: count=0");
908 1 : mime_free_attachments(list8, cnt8);
909 : }
910 :
911 : /* ── mime_decode_words: realloc when decoded output exceeds cap
912 : * (lines 429-432) ───────────────────────────────────────────────── */
913 : {
914 : /* Build a subject with two back-to-back encoded words whose combined
915 : * decoded length exceeds the initial cap (vlen*4+1 where vlen is the
916 : * input length). We achieve this by using a B-encoded word that
917 : * decodes to a longer string than the encoded representation. */
918 :
919 : /* 60 'A's base64-encoded = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFA"
920 : * Actually, base64 of N bytes = ceil(N/3)*4 chars, so 60 bytes → 80 chars.
921 : * The encoded word =?utf-8?B?...?= has 88 chars.
922 : * After the first word the cap shrinks; the second word reuses the same
923 : * to overflow. Simpler: use a single short header but make vlen small
924 : * by wrapping in a very short value parameter to mime_decode_words. */
925 :
926 : /* A 48-byte payload base64-encodes to 64 chars.
927 : * Input to mime_decode_words: "=?utf-8?B?<64-char-b64>?="
928 : * vlen = 2+5+1+64+2 = 74 chars. Initial cap = 74*4+1 = 297.
929 : * Decoded output = 48 bytes. 297 > 48 so no realloc yet.
930 : *
931 : * Two consecutive encoded words: vlen = 2*(74+1) = 150.
932 : * cap = 150*4+1 = 601. Both decode to 48 bytes = 96 total. Still fits.
933 : *
934 : * To force realloc we need dlen > cap - n, i.e. decoded output larger
935 : * than the initial cap estimate. cap = vlen*4+1. For a B-encoded word
936 : * the decoded length equals the base64 input size * 3/4. We need:
937 : * (encoded_text_len * 3 / 4) > vlen * 4
938 : * which means the encoded text must be about 5.3× the total wrapper length.
939 : * That cannot happen with normal base64.
940 : *
941 : * The realloc branch is genuinely hard to reach with valid UTF-8 input.
942 : * Instead, we test via many concatenated short encoded words so that
943 : * the cumulative n approaches and then exceeds cap during a late word. */
944 :
945 : /* Create a value whose first encoded word already decodes near cap. */
946 : /* 300 'X' chars base64-encoded = 400 chars.
947 : * Wrapper: "=?utf-8?B?" + 400 + "?=" = 410 chars.
948 : * vlen = 410. cap = 410*4+1 = 1641.
949 : * decoded = 300. n = 300.
950 : * Second word: same → n = 600. Still < 1641.
951 : *
952 : * We need at least 5 such words to exceed cap (5*300 = 1500 < 1641).
953 : * Use 6 words: n = 1800 > 1641 → realloc triggered on 6th word. */
954 :
955 : /* 300 bytes of 'X' */
956 : char payload[301];
957 1 : memset(payload, 'X', 300);
958 1 : payload[300] = '\0';
959 :
960 : /* base64-encode payload into b64buf */
961 : static const char b64chars[] =
962 : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
963 : char b64buf[401];
964 1 : size_t bi = 0;
965 101 : for (int i = 0; i < 300; i += 3) {
966 100 : unsigned int grp = ((unsigned char)payload[i] << 16) |
967 100 : ((unsigned char)payload[i+1] << 8) |
968 100 : (unsigned char)payload[i+2];
969 100 : b64buf[bi++] = b64chars[(grp >> 18) & 0x3F];
970 100 : b64buf[bi++] = b64chars[(grp >> 12) & 0x3F];
971 100 : b64buf[bi++] = b64chars[(grp >> 6) & 0x3F];
972 100 : b64buf[bi++] = b64chars[(grp ) & 0x3F];
973 : }
974 1 : b64buf[bi] = '\0';
975 :
976 : /* Build 6 adjacent encoded words (no space between them = adjacent,
977 : * so whitespace-stripping logic does not drop them). */
978 : char many_words[4096];
979 1 : int pos = 0;
980 7 : for (int w = 0; w < 6; w++) {
981 6 : pos += snprintf(many_words + pos, sizeof(many_words) - (size_t)pos,
982 : "=?utf-8?B?%s?=", b64buf);
983 : }
984 :
985 2 : RAII_STRING char *r = mime_decode_words(many_words);
986 1 : ASSERT(r != NULL, "mime_decode_words realloc: not NULL");
987 : /* Each word decodes to 300 X's; 6 words = 1800 X's */
988 1 : ASSERT(strlen(r) == 1800,
989 : "mime_decode_words realloc: total length = 1800");
990 : }
991 :
992 : /* ── mime_get_dmarc_status ──────────────────────────────────────── */
993 1 : ASSERT(mime_get_dmarc_status(NULL) == 0,
994 : "dmarc_status: NULL → 0");
995 1 : ASSERT(mime_get_dmarc_status("") == 0,
996 : "dmarc_status: empty → 0");
997 1 : ASSERT(mime_get_dmarc_status("spf=pass smtp.mailfrom=example.com") == 0,
998 : "dmarc_status: no dmarc token → 0");
999 1 : ASSERT(mime_get_dmarc_status("dmarc=pass") == 1,
1000 : "dmarc_status: dmarc=pass → 1");
1001 1 : ASSERT(mime_get_dmarc_status("DMARC=PASS") == 1,
1002 : "dmarc_status: DMARC=PASS case-insensitive → 1");
1003 1 : ASSERT(mime_get_dmarc_status("dmarc=fail") == -1,
1004 : "dmarc_status: dmarc=fail → -1");
1005 1 : ASSERT(mime_get_dmarc_status("spf=pass dmarc=pass dkim=pass") == 1,
1006 : "dmarc_status: embedded dmarc=pass → 1");
1007 1 : ASSERT(mime_get_dmarc_status("spf=pass dmarc=fail dkim=none") == -1,
1008 : "dmarc_status: embedded dmarc=fail → -1");
1009 : {
1010 : /* Full realistic Authentication-Results header value */
1011 1 : const char *ar = "mx.google.com;\n"
1012 : " dkim=pass header.i=@example.com;\n"
1013 : " spf=pass smtp.mailfrom=example.com;\n"
1014 : " dmarc=pass (p=REJECT sp=REJECT dis=NONE)"
1015 : " header.from=example.com";
1016 1 : ASSERT(mime_get_dmarc_status(ar) == 1,
1017 : "dmarc_status: realistic AR header with dmarc=pass → 1");
1018 : }
1019 : {
1020 1 : const char *ar = "mx.example.net;\n"
1021 : " dmarc=fail (p=REJECT) header.from=spoofed.com";
1022 1 : ASSERT(mime_get_dmarc_status(ar) == -1,
1023 : "dmarc_status: realistic AR header with dmarc=fail → -1");
1024 : }
1025 : /* temperror returns -2 (transient — must not be cached as checked) */
1026 1 : ASSERT(mime_get_dmarc_status("dmarc=temperror") == -2,
1027 : "dmarc_status: dmarc=temperror → -2");
1028 1 : ASSERT(mime_get_dmarc_status("DMARC=TEMPERROR") == -2,
1029 : "dmarc_status: DMARC=TEMPERROR case-insensitive → -2");
1030 1 : ASSERT(mime_get_dmarc_status("spf=pass dmarc=temperror dkim=fail") == -2,
1031 : "dmarc_status: embedded dmarc=temperror → -2");
1032 : /* permerror and none are permanent → 0, not -2 */
1033 1 : ASSERT(mime_get_dmarc_status("dmarc=permerror") == 0,
1034 : "dmarc_status: dmarc=permerror → 0 (permanent, cached)");
1035 1 : ASSERT(mime_get_dmarc_status("dmarc=none") == 0,
1036 : "dmarc_status: dmarc=none → 0 (permanent, cached)");
1037 :
1038 : /* ── mime_describe_dmarc ────────────────────────────────────────── */
1039 : {
1040 2 : RAII_STRING char *d = mime_describe_dmarc(NULL);
1041 1 : ASSERT(d && strstr(d, "no Authentication-Results"),
1042 : "describe_dmarc: NULL → mentions no Authentication-Results");
1043 : }
1044 : {
1045 2 : RAII_STRING char *d = mime_describe_dmarc("spf=pass smtp.mailfrom=x.com");
1046 1 : ASSERT(d && strstr(d, "no dmarc entry"),
1047 : "describe_dmarc: no dmarc token → no dmarc entry");
1048 : }
1049 : {
1050 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=pass header.from=good.com");
1051 1 : ASSERT(d && strncmp(d, "Pass", 4) == 0,
1052 : "describe_dmarc: dmarc=pass starts with Pass");
1053 1 : ASSERT(d && strstr(d, "good.com"),
1054 : "describe_dmarc: dmarc=pass includes header.from domain");
1055 : }
1056 : {
1057 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=pass");
1058 1 : ASSERT(d && strcmp(d, "Pass") == 0,
1059 : "describe_dmarc: dmarc=pass no header.from → exactly Pass");
1060 : }
1061 : {
1062 2 : RAII_STRING char *d = mime_describe_dmarc(
1063 : "mx.google.com; dmarc=fail (p=REJECT sp=REJECT dis=NONE) header.from=evil.com");
1064 1 : ASSERT(d && strstr(d, "reject"),
1065 : "describe_dmarc: dmarc=fail p=REJECT → mentions reject");
1066 : }
1067 : {
1068 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=fail (p=QUARANTINE)");
1069 1 : ASSERT(d && strstr(d, "quarantine"),
1070 : "describe_dmarc: dmarc=fail p=QUARANTINE → mentions quarantine");
1071 : }
1072 : {
1073 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=fail (p=NONE)");
1074 1 : ASSERT(d && strstr(d, "none"),
1075 : "describe_dmarc: dmarc=fail p=NONE → mentions none");
1076 : }
1077 : {
1078 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=fail");
1079 1 : ASSERT(d && strncmp(d, "Fail", 4) == 0,
1080 : "describe_dmarc: dmarc=fail no policy → starts with Fail");
1081 : }
1082 : {
1083 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=none");
1084 1 : ASSERT(d && strstr(d, "no DMARC policy"),
1085 : "describe_dmarc: dmarc=none → no DMARC policy");
1086 : }
1087 : {
1088 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=bestguesspass");
1089 1 : ASSERT(d && strstr(d, "Best-guess"),
1090 : "describe_dmarc: dmarc=bestguesspass → Best-guess");
1091 : }
1092 : {
1093 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=temperror");
1094 1 : ASSERT(d && strstr(d, "Temporary error"),
1095 : "describe_dmarc: dmarc=temperror → Temporary error");
1096 : }
1097 : {
1098 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=permerror");
1099 1 : ASSERT(d && strstr(d, "Permanent error"),
1100 : "describe_dmarc: dmarc=permerror → Permanent error");
1101 : }
1102 : {
1103 2 : RAII_STRING char *d = mime_describe_dmarc("dmarc=unknown42");
1104 1 : ASSERT(d && strstr(d, "Unknown"),
1105 : "describe_dmarc: unknown result → Unknown");
1106 : }
1107 : }
|