LCOV - code coverage report
Current view: top level - tests/unit - test_mime.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 99.6 % 271 270
Test Date: 2026-05-07 15:53:07 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: 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              : }
        

Generated by: LCOV version 2.0-1