LCOV - code coverage report
Current view: top level - tests/unit - test_mime.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 99.7 % 341 340
Test Date: 2026-08-21 10:11:28 Functions: 100.0 % 1 1

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

Generated by: LCOV version 2.0-1