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: empty quoted value → NULL (p == start) ────── */
348 :
349 : {
350 : /* charset="" → extract_charset returns NULL → charset_to_utf8 is
351 : * called with NULL charset and simply returns strdup(body). */
352 1 : const char *ct_empty =
353 : "Content-Type: text/plain; charset=\"\"\r\n"
354 : "\r\n"
355 : "empty charset";
356 2 : RAII_STRING char *body = mime_get_text_body(ct_empty);
357 1 : ASSERT(body != NULL, "mime_get_text_body: empty charset should not crash");
358 1 : ASSERT(strstr(body, "empty charset") != NULL,
359 : "empty charset body mismatch");
360 : }
361 :
362 : /* ── charset_to_utf8: ISO-8859-1 body via iconv ──────────────────── */
363 :
364 : {
365 : /* \xE9 = 'é' in ISO-8859-1; UTF-8 encoding: \xC3\xA9 */
366 1 : const char *iso_msg =
367 : "Content-Type: text/plain; charset=iso-8859-1\r\n"
368 : "\r\n"
369 : "\xE9t\xE9"; /* "été" in ISO-8859-1 */
370 2 : RAII_STRING char *body = mime_get_text_body(iso_msg);
371 1 : ASSERT(body != NULL,
372 : "mime_get_text_body: iso-8859-1 should not return NULL");
373 1 : ASSERT(strstr(body, "\xC3\xA9t\xC3\xA9") != NULL,
374 : "ISO-8859-1 to UTF-8 body conversion mismatch");
375 : }
376 :
377 : /* ── text_from_multipart: unquoted boundary ──────────────────────── */
378 :
379 : {
380 1 : const char *mp_unquoted =
381 : "Content-Type: multipart/mixed; boundary=NOBOUND\r\n"
382 : "\r\n"
383 : "--NOBOUND\r\n"
384 : "Content-Type: text/plain\r\n"
385 : "\r\n"
386 : "Unquoted boundary text\r\n"
387 : "--NOBOUND--\r\n";
388 2 : RAII_STRING char *body = mime_get_text_body(mp_unquoted);
389 1 : ASSERT(body != NULL,
390 : "mime_get_text_body: unquoted boundary should work");
391 1 : ASSERT(strstr(body, "Unquoted boundary text") != NULL,
392 : "Unquoted boundary multipart content mismatch");
393 : }
394 :
395 : /* ── text_from_multipart: two non-text parts → exercises loop-continue
396 : * path (lines 256-259) and closing-boundary break (line 257 final),
397 : * then returns NULL (line 261). ──────────────────────────────────── */
398 :
399 : {
400 : /* Both parts are application/octet-stream → text_from_part returns NULL
401 : * for each. After the first part the loop continues (lines 258-259),
402 : * then the second delimiter turns out to be the closing "--B3--" →
403 : * break → return NULL. */
404 1 : const char *mp_none =
405 : "Content-Type: multipart/mixed; boundary=B3\r\n"
406 : "\r\n"
407 : "--B3\r\n"
408 : "Content-Type: application/octet-stream\r\n"
409 : "\r\n"
410 : "binary1\r\n"
411 : "--B3\r\n"
412 : "Content-Type: application/octet-stream\r\n"
413 : "\r\n"
414 : "binary2\r\n"
415 : "--B3--\r\n";
416 2 : RAII_STRING char *body = mime_get_text_body(mp_none);
417 1 : ASSERT(body == NULL,
418 : "mime_get_text_body: all-binary multipart should return NULL");
419 : }
420 :
421 : /* ── mime_decode_words: ISO-8859-1 encoded word via iconv ─────────── */
422 :
423 : {
424 : /* "été": \xE9=é, \xE9=é in ISO-8859-1 Q-encoding */
425 2 : RAII_STRING char *r = mime_decode_words("=?iso-8859-1?Q?=E9t=E9?=");
426 1 : ASSERT(r != NULL,
427 : "mime_decode_words: iso-8859-1 word should not return NULL");
428 1 : ASSERT(strcmp(r, "\xC3\xA9t\xC3\xA9") == 0,
429 : "ISO-8859-1 Q-encoded word UTF-8 decode mismatch");
430 : }
431 :
432 : /* ── mime_decode_words: unknown charset → raw bytes fallback ─────── */
433 :
434 : {
435 : /* iconv_open fails for unknown charset: raw decoded bytes returned */
436 2 : RAII_STRING char *r = mime_decode_words("=?x-unknown-charset?Q?hello?=");
437 1 : ASSERT(r != NULL,
438 : "mime_decode_words: unknown charset should not return NULL");
439 1 : ASSERT(strcmp(r, "hello") == 0,
440 : "Unknown charset encoded word should pass through raw bytes");
441 : }
442 :
443 : /* ── mime_extract_imap_literal: content shorter than claimed size ── */
444 :
445 : {
446 : /* {100} claims 100 bytes but only 5 are present */
447 1 : const char *trunc = "* FETCH {100}\r\nhello";
448 2 : RAII_STRING char *lit = mime_extract_imap_literal(trunc);
449 1 : ASSERT(lit != NULL,
450 : "mime_extract_imap_literal: truncated should not return NULL");
451 1 : ASSERT(strcmp(lit, "hello") == 0,
452 : "Truncated literal should return all available bytes");
453 : }
454 :
455 : /* ── mime_get_header: long value triggers realloc (>512 bytes) ───── */
456 :
457 : {
458 : /* 580 Z's exceed the initial 512-byte buffer → realloc required */
459 : char big_msg[700];
460 1 : strcpy(big_msg, "X-Big: ");
461 1 : memset(big_msg + 7, 'Z', 580);
462 1 : strcpy(big_msg + 587, "\r\n\r\n");
463 2 : RAII_STRING char *val = mime_get_header(big_msg, "X-Big");
464 1 : ASSERT(val != NULL,
465 : "mime_get_header: 580-char value should not return NULL");
466 1 : ASSERT(strlen(val) == 580, "Long header value length mismatch");
467 : }
468 :
469 : /* ── mime_get_header: folded long value triggers realloc ─────────── */
470 :
471 : {
472 : /* 511 A's fill the buffer, then a folded continuation adds space+X.
473 : * When the fold handler tries to add the separator space, n+1==512==cap
474 : * → realloc is triggered inside the fold branch. */
475 : char fold_msg[700];
476 1 : strcpy(fold_msg, "X-Fold: "); /* 8 chars */
477 1 : memset(fold_msg + 8, 'A', 511); /* 511 A's */
478 1 : strcpy(fold_msg + 519, "\r\n X\r\n\r\n");
479 2 : RAII_STRING char *val = mime_get_header(fold_msg, "X-Fold");
480 1 : ASSERT(val != NULL,
481 : "mime_get_header: folded long value should not return NULL");
482 1 : ASSERT(strlen(val) == 513,
483 : "Folded long header value length mismatch (511 A + space + X)");
484 : }
485 :
486 : /* ── mime_get_html_part ─────────────────────────────────────────── */
487 :
488 : /* HTML-only message */
489 : {
490 1 : const char *html_msg =
491 : "Content-Type: text/html\r\n"
492 : "\r\n"
493 : "<html><body><b>Bold</b></body></html>";
494 2 : RAII_STRING char *html = mime_get_html_part(html_msg);
495 1 : ASSERT(html != NULL, "mime_get_html_part: html-only should not be NULL");
496 1 : ASSERT(strstr(html, "<b>Bold</b>") != NULL,
497 : "mime_get_html_part: html content present");
498 : }
499 :
500 : /* Plain-only message → NULL */
501 : {
502 1 : const char *plain_msg =
503 : "Content-Type: text/plain\r\n"
504 : "\r\n"
505 : "Plain only";
506 2 : RAII_STRING char *html = mime_get_html_part(plain_msg);
507 1 : ASSERT(html == NULL, "mime_get_html_part: plain-only should return NULL");
508 : }
509 :
510 : /* NULL input → NULL */
511 1 : ASSERT(mime_get_html_part(NULL) == NULL,
512 : "mime_get_html_part: NULL should return NULL");
513 :
514 : /* multipart/alternative with html part */
515 : {
516 1 : const char *alt_msg =
517 : "Content-Type: multipart/alternative; boundary=\"ALT\"\r\n"
518 : "\r\n"
519 : "--ALT\r\n"
520 : "Content-Type: text/plain\r\n"
521 : "\r\n"
522 : "Plain fallback\r\n"
523 : "--ALT\r\n"
524 : "Content-Type: text/html\r\n"
525 : "\r\n"
526 : "<p>HTML part</p>\r\n"
527 : "--ALT--\r\n";
528 2 : RAII_STRING char *html = mime_get_html_part(alt_msg);
529 1 : ASSERT(html != NULL, "mime_get_html_part: multipart/alt html should not be NULL");
530 1 : ASSERT(strstr(html, "<p>HTML part</p>") != NULL,
531 : "mime_get_html_part: multipart html content present");
532 : }
533 :
534 : /* multipart with unquoted boundary (covers html_from_multipart unquoted path) */
535 : {
536 1 : const char *unquoted_msg =
537 : "Content-Type: multipart/alternative; boundary=UNQUOTED\r\n"
538 : "\r\n"
539 : "--UNQUOTED\r\n"
540 : "Content-Type: text/html\r\n"
541 : "\r\n"
542 : "<b>unquoted</b>\r\n"
543 : "--UNQUOTED--\r\n";
544 2 : RAII_STRING char *html = mime_get_html_part(unquoted_msg);
545 1 : ASSERT(html != NULL, "mime_get_html_part: unquoted boundary not NULL");
546 1 : ASSERT(strstr(html, "<b>unquoted</b>") != NULL,
547 : "mime_get_html_part: unquoted boundary content present");
548 : }
549 :
550 : /* multipart with no HTML parts → NULL (covers html_from_multipart return NULL) */
551 : {
552 1 : const char *no_html_msg =
553 : "Content-Type: multipart/mixed; boundary=\"NOHTML\"\r\n"
554 : "\r\n"
555 : "--NOHTML\r\n"
556 : "Content-Type: text/plain\r\n"
557 : "\r\n"
558 : "plain only\r\n"
559 : "--NOHTML--\r\n";
560 2 : RAII_STRING char *html = mime_get_html_part(no_html_msg);
561 1 : ASSERT(html == NULL, "mime_get_html_part: no-html multipart should return NULL");
562 : }
563 :
564 : /* ── mime_list_attachments: single base64 attachment ─────────────── */
565 :
566 : {
567 : /* "dGVzdA==" is base64 for "test" */
568 1 : const char *mime =
569 : "MIME-Version: 1.0\r\n"
570 : "Content-Type: multipart/mixed; boundary=\"B001\"\r\n"
571 : "\r\n"
572 : "--B001\r\n"
573 : "Content-Type: text/plain\r\n"
574 : "\r\n"
575 : "Body\r\n"
576 : "--B001\r\n"
577 : "Content-Type: application/octet-stream; name=\"file.bin\"\r\n"
578 : "Content-Disposition: attachment; filename=\"file.bin\"\r\n"
579 : "Content-Transfer-Encoding: base64\r\n"
580 : "\r\n"
581 : "dGVzdA==\r\n"
582 : "--B001--\r\n";
583 :
584 1 : int count = 0;
585 1 : MimeAttachment *list = mime_list_attachments(mime, &count);
586 1 : ASSERT(list != NULL, "list_attachments: not NULL");
587 1 : ASSERT(count == 1, "list_attachments: count=1");
588 1 : if (list && count > 0) {
589 1 : ASSERT(strcmp(list[0].filename, "file.bin") == 0,
590 : "list_attachments: filename is file.bin");
591 1 : ASSERT(list[0].data != NULL, "list_attachments: data not NULL");
592 : }
593 1 : mime_free_attachments(list, count);
594 : }
595 :
596 : /* ── mime_list_attachments: NULL input ───────────────────────────── */
597 :
598 : {
599 1 : int count = -1;
600 1 : MimeAttachment *list = mime_list_attachments(NULL, &count);
601 1 : ASSERT(list == NULL, "list_attachments: NULL msg returns NULL");
602 1 : ASSERT(count == 0, "list_attachments: NULL msg sets count=0");
603 : }
604 :
605 : /* ── mime_list_attachments: plain text — no attachments ──────────── */
606 :
607 : {
608 1 : const char *plain = "Content-Type: text/plain\r\n\r\nHello";
609 1 : int count = 0;
610 1 : MimeAttachment *list = mime_list_attachments(plain, &count);
611 1 : ASSERT(count == 0, "no attachments: count=0");
612 1 : mime_free_attachments(list, count);
613 : }
614 :
615 : /* ── mime_list_attachments: quoted filename ───────────────────────── */
616 :
617 : {
618 1 : const char *mime_q =
619 : "MIME-Version: 1.0\r\n"
620 : "Content-Type: multipart/mixed; boundary=\"B002\"\r\n"
621 : "\r\n"
622 : "--B002\r\n"
623 : "Content-Type: application/pdf; name=\"quoted file.pdf\"\r\n"
624 : "Content-Disposition: attachment; filename=\"quoted file.pdf\"\r\n"
625 : "\r\n"
626 : "data\r\n"
627 : "--B002--\r\n";
628 1 : int count2 = 0;
629 1 : MimeAttachment *list2 = mime_list_attachments(mime_q, &count2);
630 1 : ASSERT(count2 == 1, "quoted filename: count=1");
631 1 : if (list2 && count2 > 0)
632 1 : ASSERT(strstr(list2[0].filename, "quoted") != NULL,
633 : "quoted filename: name contains 'quoted'");
634 1 : mime_free_attachments(list2, count2);
635 : }
636 :
637 : /* ── mime_list_attachments: multiple attachments ─────────────────── */
638 :
639 : {
640 1 : const char *mime2 =
641 : "Content-Type: multipart/mixed; boundary=\"B003\"\r\n"
642 : "\r\n"
643 : "--B003\r\n"
644 : "Content-Type: application/pdf; name=\"doc.pdf\"\r\n"
645 : "Content-Disposition: attachment; filename=\"doc.pdf\"\r\n"
646 : "\r\n"
647 : "pdfdata\r\n"
648 : "--B003\r\n"
649 : "Content-Type: image/png; name=\"img.png\"\r\n"
650 : "Content-Disposition: attachment; filename=\"img.png\"\r\n"
651 : "\r\n"
652 : "imgdata\r\n"
653 : "--B003--\r\n";
654 1 : int cnt = 0;
655 1 : MimeAttachment *ml = mime_list_attachments(mime2, &cnt);
656 1 : ASSERT(cnt == 2, "multi attachments: count=2");
657 1 : mime_free_attachments(ml, cnt);
658 : }
659 :
660 : /* ── mime_save_attachment: write decoded content to disk ─────────── */
661 :
662 : {
663 : /* base64 "dGVzdA==" = "test" (4 bytes) */
664 1 : const char *mime3 =
665 : "Content-Type: multipart/mixed; boundary=\"B004\"\r\n"
666 : "\r\n"
667 : "--B004\r\n"
668 : "Content-Type: application/octet-stream; name=\"save.bin\"\r\n"
669 : "Content-Disposition: attachment; filename=\"save.bin\"\r\n"
670 : "Content-Transfer-Encoding: base64\r\n"
671 : "\r\n"
672 : "dGVzdA==\r\n"
673 : "--B004--\r\n";
674 :
675 1 : int cnt3 = 0;
676 1 : MimeAttachment *list3 = mime_list_attachments(mime3, &cnt3);
677 1 : ASSERT(cnt3 == 1, "save_attachment: list count=1");
678 1 : if (list3 && cnt3 > 0) {
679 1 : char tmpdir[] = "/tmp/mime_test_XXXXXX";
680 1 : char *dir = mkdtemp(tmpdir);
681 1 : ASSERT(dir != NULL, "save_attachment: mkdtemp succeeded");
682 1 : if (dir) {
683 : char path[256];
684 1 : snprintf(path, sizeof(path), "%s/%s", dir, list3[0].filename);
685 1 : int saved = mime_save_attachment(&list3[0], path);
686 1 : ASSERT(saved == 0, "save_attachment: returns 0");
687 1 : ASSERT(access(path, F_OK) == 0, "save_attachment: file exists");
688 1 : unlink(path);
689 1 : rmdir(dir);
690 : }
691 : }
692 1 : mime_free_attachments(list3, cnt3);
693 : }
694 :
695 : /* ── mime_save_attachment: NULL inputs ───────────────────────────── */
696 :
697 : {
698 1 : int rc_null = mime_save_attachment(NULL, "/tmp/irrelevant");
699 1 : ASSERT(rc_null == -1, "save_attachment: NULL att returns -1");
700 : }
701 :
702 : /* ── mime_list_attachments: explicit attachment, no filename → auto-name */
703 :
704 : {
705 1 : const char *mime4 =
706 : "Content-Type: multipart/mixed; boundary=\"B005\"\r\n"
707 : "\r\n"
708 : "--B005\r\n"
709 : "Content-Type: application/octet-stream\r\n"
710 : "Content-Disposition: attachment\r\n"
711 : "\r\n"
712 : "binarydata\r\n"
713 : "--B005--\r\n";
714 1 : int cnt4 = 0;
715 1 : MimeAttachment *list4 = mime_list_attachments(mime4, &cnt4);
716 1 : ASSERT(cnt4 == 1, "auto-name attachment: count=1");
717 1 : if (list4 && cnt4 > 0)
718 1 : ASSERT(list4[0].filename != NULL, "auto-name attachment: filename not NULL");
719 1 : mime_free_attachments(list4, cnt4);
720 : }
721 :
722 : /* ── mime_list_attachments: unquoted boundary ─────────────────────── */
723 :
724 : {
725 1 : const char *mime5 =
726 : "Content-Type: multipart/mixed; boundary=UBND\r\n"
727 : "\r\n"
728 : "--UBND\r\n"
729 : "Content-Type: application/zip; name=\"archive.zip\"\r\n"
730 : "Content-Disposition: attachment; filename=\"archive.zip\"\r\n"
731 : "\r\n"
732 : "zipdata\r\n"
733 : "--UBND--\r\n";
734 1 : int cnt5 = 0;
735 1 : MimeAttachment *list5 = mime_list_attachments(mime5, &cnt5);
736 1 : ASSERT(cnt5 == 1, "unquoted boundary attachment: count=1");
737 1 : if (list5 && cnt5 > 0)
738 1 : ASSERT(strcmp(list5[0].filename, "archive.zip") == 0,
739 : "unquoted boundary attachment: filename correct");
740 1 : mime_free_attachments(list5, cnt5);
741 : }
742 :
743 : /* ── extract_param: unquoted value (lines 590-595) ───────────────── */
744 : /* A Content-Disposition with unquoted filename triggers the unquoted
745 : * extraction path in extract_param. */
746 : {
747 1 : const char *mime6 =
748 : "Content-Type: multipart/mixed; boundary=\"B006\"\r\n"
749 : "\r\n"
750 : "--B006\r\n"
751 : "Content-Type: application/octet-stream\r\n"
752 : "Content-Disposition: attachment; filename=unquoted.bin\r\n"
753 : "\r\n"
754 : "binarydata\r\n"
755 : "--B006--\r\n";
756 1 : int cnt6 = 0;
757 1 : MimeAttachment *list6 = mime_list_attachments(mime6, &cnt6);
758 1 : ASSERT(cnt6 == 1, "unquoted filename param: count=1");
759 1 : if (list6 && cnt6 > 0)
760 1 : ASSERT(strcmp(list6[0].filename, "unquoted.bin") == 0,
761 : "unquoted filename param: name correct");
762 1 : mime_free_attachments(list6, cnt6);
763 : }
764 :
765 : /* ── collect_parts: text/plain with name= but no attachment disp
766 : * → body part, skip (lines 715-718) ───────────────────────────── */
767 : {
768 : /* text/plain with a name parameter but Content-Disposition: inline
769 : * (not "attachment") → should be treated as a body part, not an
770 : * attachment. count must be 0. */
771 1 : const char *mime7 =
772 : "Content-Type: multipart/mixed; boundary=\"B007\"\r\n"
773 : "\r\n"
774 : "--B007\r\n"
775 : "Content-Type: text/plain; name=readme.txt\r\n"
776 : "Content-Disposition: inline\r\n"
777 : "\r\n"
778 : "inline text\r\n"
779 : "--B007--\r\n";
780 1 : int cnt7 = 0;
781 1 : MimeAttachment *list7 = mime_list_attachments(mime7, &cnt7);
782 1 : ASSERT(cnt7 == 0, "text/plain name= no attachment disp: count=0");
783 1 : mime_free_attachments(list7, cnt7);
784 : }
785 :
786 : /* ── collect_parts: text/html with name= but no attachment disp
787 : * → body part, skip (hits text/html branch of || on line 716) ─── */
788 : {
789 1 : const char *mime7b =
790 : "Content-Type: multipart/mixed; boundary=\"B007B\"\r\n"
791 : "\r\n"
792 : "--B007B\r\n"
793 : "Content-Type: text/html; name=page.html\r\n"
794 : "Content-Disposition: inline\r\n"
795 : "\r\n"
796 : "<p>inline html</p>\r\n"
797 : "--B007B--\r\n";
798 1 : int cnt7b = 0;
799 1 : MimeAttachment *list7b = mime_list_attachments(mime7b, &cnt7b);
800 1 : ASSERT(cnt7b == 0, "text/html name= no attachment disp: count=0");
801 1 : mime_free_attachments(list7b, cnt7b);
802 : }
803 :
804 : /* ── collect_parts: no body separator → early return (lines 724-725) */
805 : {
806 : /* An attachment part that has no blank line (no body separator) —
807 : * body_start() returns NULL → collect_parts returns without adding. */
808 1 : const char *mime8 =
809 : "Content-Type: multipart/mixed; boundary=\"B008\"\r\n"
810 : "\r\n"
811 : "--B008\r\n"
812 : "Content-Type: application/octet-stream\r\n"
813 : "Content-Disposition: attachment; filename=noseq.bin\r\n"
814 : "--B008--\r\n";
815 1 : int cnt8 = 0;
816 1 : MimeAttachment *list8 = mime_list_attachments(mime8, &cnt8);
817 1 : ASSERT(cnt8 == 0, "attachment no body separator: count=0");
818 1 : mime_free_attachments(list8, cnt8);
819 : }
820 :
821 : /* ── mime_decode_words: realloc when decoded output exceeds cap
822 : * (lines 429-432) ───────────────────────────────────────────────── */
823 : {
824 : /* Build a subject with two back-to-back encoded words whose combined
825 : * decoded length exceeds the initial cap (vlen*4+1 where vlen is the
826 : * input length). We achieve this by using a B-encoded word that
827 : * decodes to a longer string than the encoded representation. */
828 :
829 : /* 60 'A's base64-encoded = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFA"
830 : * Actually, base64 of N bytes = ceil(N/3)*4 chars, so 60 bytes → 80 chars.
831 : * The encoded word =?utf-8?B?...?= has 88 chars.
832 : * After the first word the cap shrinks; the second word reuses the same
833 : * to overflow. Simpler: use a single short header but make vlen small
834 : * by wrapping in a very short value parameter to mime_decode_words. */
835 :
836 : /* A 48-byte payload base64-encodes to 64 chars.
837 : * Input to mime_decode_words: "=?utf-8?B?<64-char-b64>?="
838 : * vlen = 2+5+1+64+2 = 74 chars. Initial cap = 74*4+1 = 297.
839 : * Decoded output = 48 bytes. 297 > 48 so no realloc yet.
840 : *
841 : * Two consecutive encoded words: vlen = 2*(74+1) = 150.
842 : * cap = 150*4+1 = 601. Both decode to 48 bytes = 96 total. Still fits.
843 : *
844 : * To force realloc we need dlen > cap - n, i.e. decoded output larger
845 : * than the initial cap estimate. cap = vlen*4+1. For a B-encoded word
846 : * the decoded length equals the base64 input size * 3/4. We need:
847 : * (encoded_text_len * 3 / 4) > vlen * 4
848 : * which means the encoded text must be about 5.3× the total wrapper length.
849 : * That cannot happen with normal base64.
850 : *
851 : * The realloc branch is genuinely hard to reach with valid UTF-8 input.
852 : * Instead, we test via many concatenated short encoded words so that
853 : * the cumulative n approaches and then exceeds cap during a late word. */
854 :
855 : /* Create a value whose first encoded word already decodes near cap. */
856 : /* 300 'X' chars base64-encoded = 400 chars.
857 : * Wrapper: "=?utf-8?B?" + 400 + "?=" = 410 chars.
858 : * vlen = 410. cap = 410*4+1 = 1641.
859 : * decoded = 300. n = 300.
860 : * Second word: same → n = 600. Still < 1641.
861 : *
862 : * We need at least 5 such words to exceed cap (5*300 = 1500 < 1641).
863 : * Use 6 words: n = 1800 > 1641 → realloc triggered on 6th word. */
864 :
865 : /* 300 bytes of 'X' */
866 : char payload[301];
867 1 : memset(payload, 'X', 300);
868 1 : payload[300] = '\0';
869 :
870 : /* base64-encode payload into b64buf */
871 : static const char b64chars[] =
872 : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
873 : char b64buf[401];
874 1 : size_t bi = 0;
875 101 : for (int i = 0; i < 300; i += 3) {
876 100 : unsigned int grp = ((unsigned char)payload[i] << 16) |
877 100 : ((unsigned char)payload[i+1] << 8) |
878 100 : (unsigned char)payload[i+2];
879 100 : b64buf[bi++] = b64chars[(grp >> 18) & 0x3F];
880 100 : b64buf[bi++] = b64chars[(grp >> 12) & 0x3F];
881 100 : b64buf[bi++] = b64chars[(grp >> 6) & 0x3F];
882 100 : b64buf[bi++] = b64chars[(grp ) & 0x3F];
883 : }
884 1 : b64buf[bi] = '\0';
885 :
886 : /* Build 6 adjacent encoded words (no space between them = adjacent,
887 : * so whitespace-stripping logic does not drop them). */
888 : char many_words[4096];
889 1 : int pos = 0;
890 7 : for (int w = 0; w < 6; w++) {
891 6 : pos += snprintf(many_words + pos, sizeof(many_words) - (size_t)pos,
892 : "=?utf-8?B?%s?=", b64buf);
893 : }
894 :
895 2 : RAII_STRING char *r = mime_decode_words(many_words);
896 1 : ASSERT(r != NULL, "mime_decode_words realloc: not NULL");
897 : /* Each word decodes to 300 X's; 6 words = 1800 X's */
898 1 : ASSERT(strlen(r) == 1800,
899 : "mime_decode_words realloc: total length = 1800");
900 : }
901 : }
|