Add a 5th CRLF bug fix (mu_header_size), a malformed-mail robustness
Debian Trixie package bundle / packages (push) Successful in 22m27s

test suite, and fix a missed symbols-file entry

mu_header_size() (libmailutils/mailbox/header.c) hard-coded a 1-byte
blank-line separator ("+1") regardless of what was actually parsed;
for a CRLF-terminated message the real separator is 2 bytes, so every
non-multipart message's body picked up the blank line's own trailing
LF as a spurious leading byte. This is a different code path from the
four multipart-specific bugs already fixed here -- it's used by every
plain message, not just multipart ones. Fixed by having header_parse()
record the actual separator width it found (a new eol_size field) and
mu_header_size() use that instead of a hard-coded constant, falling
back to the original "+1" only when parsing never found a blank line
at all, to leave that untested edge case's behavior unchanged. New
regression tests (hdrsize.c/.at) confirmed to fail without the fix and
pass with it.

Also adds a permanent malformed-mail robustness test suite
(mimerobust.c/.at, 13 cases: missing/empty boundary, a boundary that
never appears, truncated input, no closing boundary, nested
multipart, boundary values with regex-special characters, and more)
verifying mu_message_is_multipart()/get_num_parts()/get_part() handle
all of them with a clean error or a sensible part count -- never a
crash or a hang -- so that stays true as the parser evolves, requested
explicitly rather than left as a one-off ad hoc check.

Separately: 0004's mu_message_default_envelope_recipient() export was
never added to debian/libmailutils9t64.symbols, the same class of
oversight already fixed once for the body.c/envelope-recipient
symbols. Caught by re-running the real dpkg-buildpackage verification
after this patch, which failed with the identical dh_makeshlibs error
as before. Fixed in 0002-debian-trixie.patch alongside the existing
entries.

Full stack (0001, 0003, 0004, extended 0005) re-verified clean: 777/777
libmailutils tests (Gentoo distfile), 756/756 (Debian 3.19, different
upstream baseline), 116/116 and 114/114 sieve tests respectively, zero
regressions, via both the cached Gentoo distfile and a genuine
dpkg-buildpackage build of Debian's source in a fresh Trixie container.
Deployed live, bongo.service active, SIEVE-04 passing.
This commit is contained in:
Mario Fetka
2026-08-01 20:31:12 +02:00
parent a139a297e5
commit b4d0e6648d
2 changed files with 739 additions and 23 deletions
@@ -13,9 +13,9 @@ index e01e7a1..4bfb44e 100644
mailutils (1:3.19-1) unstable; urgency=medium
* New upstream release.
diff --git a/debian/libmailutils9t64.symbols b/debian/libmailutils9t64.symbols
--- a/debian/libmailutils9t64.symbols 2026-08-01 14:44:36.767753030 +0200
+++ b/debian/libmailutils9t64.symbols 2026-08-01 14:44:36.768444316 +0200
diff -uNr a/debian/libmailutils9t64.symbols b/debian/libmailutils9t64.symbols
--- a/debian/libmailutils9t64.symbols 2026-08-01 20:04:58.507681247 +0200
+++ b/debian/libmailutils9t64.symbols 2026-08-01 20:04:58.508435596 +0200
@@ -503,16 +503,20 @@
mu_dot_filter@Base 1:3.17
mu_dq_filter@Base 1:3.17
@@ -37,7 +37,15 @@ diff --git a/debian/libmailutils9t64.symbols b/debian/libmailutils9t64.symbols
mu_envelope_sget_sender@Base 1:3.17
mu_errname@Base 1:3.17
mu_error@Base 1:3.17
@@ -2071,6 +2075,8 @@
@@ -978,6 +982,7 @@
mu_message_create@Base 1:3.17
mu_message_create_attachment@Base 1:3.17
mu_message_create_copy@Base 1:3.17
+ mu_message_default_envelope_recipient@Base 1:3.19-1+bongo1
mu_message_destroy@Base 1:3.17
mu_message_encapsulate@Base 1:3.17
mu_message_from_stream_with_envelope@Base 1:3.17
@@ -2071,6 +2076,8 @@
mod_upperfirst@Base 1:3.17
mu_cli_capa_sieve@Base 1:3.17
mu_i_sv_2nrealloc@Base 1:3.17
@@ -46,7 +54,7 @@ diff --git a/debian/libmailutils9t64.symbols b/debian/libmailutils9t64.symbols
mu_i_sv_code@Base 1:3.17
mu_i_sv_code_action@Base 1:3.17
mu_i_sv_code_test@Base 1:3.17
@@ -2234,6 +2240,7 @@
@@ -2234,6 +2241,7 @@
sieve_get_address_part@Base 1:3.17
sieve_run@Base 1:3.17
sieve_test_address@Base 1:3.17
@@ -1,6 +1,77 @@
diff -uNr mailutils-3.21.orig/include/mailutils/sys/header.h mailutils-3.21/include/mailutils/sys/header.h
--- mailutils-3.21.orig/include/mailutils/sys/header.h 2025-01-02 14:36:28.000000000 +0100
+++ mailutils-3.21/include/mailutils/sys/header.h 2026-08-01 19:43:55.816935615 +0200
@@ -56,7 +56,14 @@
size_t numhdr;
size_t numlines;
size_t size;
-
+ /* Width in bytes of the blank line separating headers from body, as
+ actually found while parsing ("\n" -> 1, "\r\n" -> 2), or 0 if
+ parsing never found one (e.g. a header-only message). Lets
+ mu_header_size() add the real separator width instead of
+ hard-coding 1, which corrupted the body offset of every
+ CRLF-terminated message by one byte. */
+ size_t eol_size;
+
/* Temporary storage */
mu_stream_t mstream;
diff -uNr mailutils-3.21.orig/libmailutils/mailbox/header.c mailutils-3.21/libmailutils/mailbox/header.c
--- mailutils-3.21.orig/libmailutils/mailbox/header.c 2025-01-02 14:36:31.000000000 +0100
+++ mailutils-3.21/libmailutils/mailbox/header.c 2026-08-01 19:43:55.816307754 +0200
@@ -322,6 +322,7 @@
return 0;
header->flags |= HEADER_INVALIDATE;
+ header->eol_size = 0;
mu_hdrent_free_list (header);
/* Get a header, a header is:
@@ -335,9 +336,23 @@
const char *fn, *fn_end, *fv, *fv_end;
struct mu_hdrent *ent;
+ /* A "\r\n" blank line previously fell through to the "no colon
+ found" malformed-header fallback below instead of being
+ recognized here directly -- it still stopped parsing (by
+ accident, not by design), but never recorded how wide the
+ real separator was, which is what the fix below needs. */
+ if (header_start[0] == '\n')
+ {
+ header->eol_size = 1;
+ break;
+ }
+ if (header_start[0] == '\r' && len > 1 && header_start[1] == '\n')
+ {
+ header->eol_size = 2;
+ break;
+ }
if (header_start[0] == ' '
- || header_start[0] == '\t'
- || header_start[0] == '\n')
+ || header_start[0] == '\t')
break;
/* Second loop extract one header field. */
@@ -980,7 +995,13 @@
size_t size;
size_t lines;
mu_hdrent_count (header, &count, &size, &lines);
- *psize = size + 1;
+ /* header->eol_size is the real width of the blank line found
+ while parsing ("\n" -> 1, "\r\n" -> 2); hard-coding 1
+ corrupted the body offset of every CRLF-terminated message by
+ one byte. Fall back to the original "+1" when parsing never
+ found a blank line at all (e.g. a header-only message), to
+ keep that edge case's existing behavior unchanged. */
+ *psize = size + (header->eol_size ? header->eol_size : 1);
}
return status;
}
diff -uNr mailutils-3.21.orig/libmailutils/mime/mime.c mailutils-3.21/libmailutils/mime/mime.c
--- mailutils-3.21.orig/libmailutils/mime/mime.c 2026-08-01 17:13:57.198386442 +0200
+++ mailutils-3.21/libmailutils/mime/mime.c 2026-08-01 17:13:57.199012148 +0200
--- mailutils-3.21.orig/libmailutils/mime/mime.c 2025-01-02 14:36:32.000000000 +0100
+++ mailutils-3.21/libmailutils/mime/mime.c 2026-08-01 19:43:43.338641164 +0200
@@ -263,6 +263,14 @@
{
if (line[llen-1] == '\n')
@@ -72,8 +143,8 @@ diff -uNr mailutils-3.21.orig/libmailutils/mime/mime.c mailutils-3.21/libmailuti
}
}
diff -uNr mailutils-3.21.orig/libmailutils/string/unfold.c mailutils-3.21/libmailutils/string/unfold.c
--- mailutils-3.21.orig/libmailutils/string/unfold.c 2026-08-01 17:13:57.197250734 +0200
+++ mailutils-3.21/libmailutils/string/unfold.c 2026-08-01 17:13:57.197872137 +0200
--- mailutils-3.21.orig/libmailutils/string/unfold.c 2025-01-02 14:36:33.000000000 +0100
+++ mailutils-3.21/libmailutils/string/unfold.c 2026-08-01 19:43:43.338729163 +0200
@@ -39,6 +39,20 @@
case uf_init:
if (*q == '\n')
@@ -95,9 +166,166 @@ diff -uNr mailutils-3.21.orig/libmailutils/string/unfold.c mailutils-3.21/libmai
else
*p++ = *q;
break;
diff -uNr mailutils-3.21.orig/libmailutils/tests/hdrsize.at mailutils-3.21/libmailutils/tests/hdrsize.at
--- mailutils-3.21.orig/libmailutils/tests/hdrsize.at 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/hdrsize.at 2026-08-01 19:43:55.820094939 +0200
@@ -0,0 +1,55 @@
+# This file is part of GNU Mailutils. -*- Autotest -*-
+# Copyright (C) 2026 Mario Fetka.
+#
+# GNU Mailutils is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3, or (at
+# your option) any later version.
+#
+# GNU Mailutils is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with GNU Mailutils. If not, see <http://www.gnu.org/licenses/>.
+
+AT_BANNER([Body offset from header size])
+
+# mu_header_size() hard-coded a 1-byte blank-line separator ("+1")
+# regardless of what was actually parsed; for a CRLF-terminated
+# message (i.e. any real SMTP message) the real separator is 2 bytes,
+# so the body's offset into the raw message started one byte too
+# early, picking up the blank line's own trailing LF as a spurious
+# leading byte of body content. Only affects non-multipart messages --
+# multipart body offsets go through a completely separate code path in
+# libmailutils/mime/mime.c (see mimeparts.at).
+
+AT_SETUP([non-multipart body, CRLF terminated])
+AT_KEYWORDS([hdrsize unfold crlf])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: text/plain
+
+just a normal message
+EOT
+hdrsize < input
+],
+[0],
+[just a normal message\x0d\x0a
+])
+AT_CLEANUP
+
+AT_SETUP([non-multipart body, LF terminated])
+AT_KEYWORDS([hdrsize unfold])
+AT_CHECK([
+AT_DATA([input],[Content-Type: text/plain
+
+just a normal message
+])
+hdrsize < input
+],
+[0],
+[just a normal message\x0a
+])
+AT_CLEANUP
diff -uNr mailutils-3.21.orig/libmailutils/tests/hdrsize.c mailutils-3.21/libmailutils/tests/hdrsize.c
--- mailutils-3.21.orig/libmailutils/tests/hdrsize.c 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/hdrsize.c 2026-08-01 19:43:55.819127126 +0200
@@ -0,0 +1,94 @@
+/* GNU Mailutils -- a suite of utilities for electronic mail
+ Copyright (C) 2026 Mario Fetka.
+
+ GNU Mailutils is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 3, or (at your option)
+ any later version.
+
+ GNU Mailutils is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with GNU Mailutils. If not, see <http://www.gnu.org/licenses/>. */
+
+/* mu_message_get_body() test: builds a message the same minimal way
+ an application embedding libmailutils typically does, then prints
+ its body with any byte outside printable ASCII shown as \xHH, so a
+ stray or missing separator byte is visible in test output instead
+ of silently vanishing into a terminal. Exists to catch a regression
+ of mu_header_size()'s blank-line width (1 byte for "\n", 2 for
+ "\r\n") being hard-coded again -- that previously corrupted the
+ body of every CRLF-terminated, non-multipart message with a
+ spurious leading byte. */
+
+#ifdef HAVE_CONFIG_H
+# include <config.h>
+#endif
+#include <mailutils/mailutils.h>
+
+int
+main (void)
+{
+ mu_message_t msg;
+ mu_stream_t stream, memstream;
+ mu_body_t body;
+ mu_stream_t bstream;
+ mu_off_t size;
+ char *buf;
+ size_t n = 0, i;
+
+ mu_set_program_name ("hdrsize");
+ mu_stdstream_setup (MU_STDSTREAM_RESET_NONE);
+
+ MU_ASSERT (mu_memory_stream_create (&memstream, MU_STREAM_RDWR));
+ MU_ASSERT (mu_stream_copy (memstream, mu_strin, 0, NULL));
+ MU_ASSERT (mu_stream_size (memstream, &size));
+
+ buf = malloc (size ? size : 1);
+ if (size)
+ {
+ MU_ASSERT (mu_stream_seek (memstream, 0, MU_SEEK_SET, NULL));
+ MU_ASSERT (mu_stream_read (memstream, buf, size, &n));
+ }
+ MU_ASSERT (mu_static_memory_stream_create (&stream, buf, n));
+ MU_ASSERT (mu_message_create (&msg, NULL));
+ MU_ASSERT (mu_message_set_stream (msg, stream, NULL));
+
+ {
+ mu_header_t header;
+ /* mu_message_get_body() relies on msg->orig_header_size, which is
+ only computed as a side effect of mu_message_get_header()'s
+ lazy header parse -- it must run first. */
+ MU_ASSERT (mu_message_get_header (msg, &header));
+ }
+ MU_ASSERT (mu_message_get_body (msg, &body));
+ MU_ASSERT (mu_body_get_streamref (body, &bstream));
+
+ {
+ mu_stream_t out;
+ char *value;
+ MU_ASSERT (mu_memory_stream_create (&out, MU_STREAM_RDWR));
+ MU_ASSERT (mu_stream_copy (out, bstream, 0, NULL));
+ MU_ASSERT (mu_stream_size (out, &size));
+ value = malloc (size + 1);
+ MU_ASSERT (mu_stream_seek (out, 0, MU_SEEK_SET, NULL));
+ MU_ASSERT (mu_stream_read (out, value, size, NULL));
+ value[size] = 0;
+
+ for (i = 0; value[i]; i++)
+ {
+ unsigned char c = (unsigned char) value[i];
+ if (c >= 32 && c < 127)
+ mu_printf ("%c", c);
+ else
+ mu_printf ("\\x%02x", c);
+ }
+ mu_printf ("\n");
+ free (value);
+ }
+
+ return 0;
+}
diff -uNr mailutils-3.21.orig/libmailutils/tests/hdrunfold.at mailutils-3.21/libmailutils/tests/hdrunfold.at
--- mailutils-3.21.orig/libmailutils/tests/hdrunfold.at 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/hdrunfold.at 2026-08-01 17:13:57.200238203 +0200
+++ mailutils-3.21/libmailutils/tests/hdrunfold.at 2026-08-01 19:43:43.338768881 +0200
@@ -0,0 +1,76 @@
+# This file is part of GNU Mailutils. -*- Autotest -*-
+# Copyright (C) 2026 Mario Fetka.
@@ -177,7 +405,7 @@ diff -uNr mailutils-3.21.orig/libmailutils/tests/hdrunfold.at mailutils-3.21/lib
+AT_CLEANUP
diff -uNr mailutils-3.21.orig/libmailutils/tests/hdrunfold.c mailutils-3.21/libmailutils/tests/hdrunfold.c
--- mailutils-3.21.orig/libmailutils/tests/hdrunfold.c 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/hdrunfold.c 2026-08-01 17:13:57.199610353 +0200
+++ mailutils-3.21/libmailutils/tests/hdrunfold.c 2026-08-01 19:43:43.338812402 +0200
@@ -0,0 +1,74 @@
+/* GNU Mailutils -- a suite of utilities for electronic mail
+ Copyright (C) 2026 Mario Fetka.
@@ -254,43 +482,47 @@ diff -uNr mailutils-3.21.orig/libmailutils/tests/hdrunfold.c mailutils-3.21/libm
+ return 0;
+}
diff -uNr mailutils-3.21.orig/libmailutils/tests/Makefile.am mailutils-3.21/libmailutils/tests/Makefile.am
--- mailutils-3.21.orig/libmailutils/tests/Makefile.am 2026-08-01 17:13:57.312124568 +0200
+++ mailutils-3.21/libmailutils/tests/Makefile.am 2026-08-01 17:13:57.201787328 +0200
@@ -43,6 +43,7 @@
--- mailutils-3.21.orig/libmailutils/tests/Makefile.am 2025-09-17 17:55:23.000000000 +0200
+++ mailutils-3.21/libmailutils/tests/Makefile.am 2026-08-01 19:43:55.821070055 +0200
@@ -43,6 +43,8 @@
fsfolder\
globtest\
hdrcpy\
+ hdrsize\
+ hdrunfold\
imapio\
lck\
listop\
@@ -53,6 +54,7 @@
@@ -53,6 +55,8 @@
mcf\
mimehdr\
mimeitr\
+ mimeparts\
+ mimerobust\
modtofsaf\
msgset\
modmesg\
@@ -116,6 +118,7 @@
@@ -116,6 +120,8 @@
fsfolder03.at\
fsfolder04.at\
hdrcpy.at\
+ hdrunfold.at\
+ hdrsize.at\
hdrflt.at\
htmlent.at\
globtest.at\
@@ -128,6 +131,7 @@
@@ -128,6 +134,8 @@
logstr.at\
mailcap.at\
mimehdr.at\
+ mimeparts.at\
+ mimerobust.at\
modmesg.at\
modtofsaf.at\
msgset.at\
diff -uNr mailutils-3.21.orig/libmailutils/tests/mimeparts.at mailutils-3.21/libmailutils/tests/mimeparts.at
--- mailutils-3.21.orig/libmailutils/tests/mimeparts.at 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/mimeparts.at 2026-08-01 17:13:57.201295704 +0200
+++ mailutils-3.21/libmailutils/tests/mimeparts.at 2026-08-01 19:43:43.338864049 +0200
@@ -0,0 +1,55 @@
+# This file is part of GNU Mailutils. -*- Autotest -*-
+# Copyright (C) 2026 Mario Fetka.
@@ -349,7 +581,7 @@ diff -uNr mailutils-3.21.orig/libmailutils/tests/mimeparts.at mailutils-3.21/lib
+AT_CLEANUP
diff -uNr mailutils-3.21.orig/libmailutils/tests/mimeparts.c mailutils-3.21/libmailutils/tests/mimeparts.c
--- mailutils-3.21.orig/libmailutils/tests/mimeparts.c 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/mimeparts.c 2026-08-01 17:13:57.200762999 +0200
+++ mailutils-3.21/libmailutils/tests/mimeparts.c 2026-08-01 19:43:43.338904830 +0200
@@ -0,0 +1,96 @@
+/* GNU Mailutils -- a suite of utilities for electronic mail
+ Copyright (C) 2026 Mario Fetka.
@@ -447,22 +679,498 @@ diff -uNr mailutils-3.21.orig/libmailutils/tests/mimeparts.c mailutils-3.21/libm
+ mu_message_unref (msg);
+ return 0;
+}
diff -uNr mailutils-3.21.orig/libmailutils/tests/mimerobust.at mailutils-3.21/libmailutils/tests/mimerobust.at
--- mailutils-3.21.orig/libmailutils/tests/mimerobust.at 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/mimerobust.at 2026-08-01 19:43:55.818182225 +0200
@@ -0,0 +1,283 @@
+# This file is part of GNU Mailutils. -*- Autotest -*-
+# Copyright (C) 2026 Mario Fetka.
+#
+# GNU Mailutils is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3, or (at
+# your option) any later version.
+#
+# GNU Mailutils is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with GNU Mailutils. If not, see <http://www.gnu.org/licenses/>.
+
+AT_BANNER([Multipart robustness against malformed input])
+
+dnl Each case here is a deliberately malformed multipart message --
+dnl missing/empty boundary, a boundary that never appears, truncated
+dnl input, no closing boundary, nested multipart, and so on. None of
+dnl these demonstrate a fix the way mimeparts.at's CRLF cases do; the
+dnl point is only that mu_message_is_multipart()/get_num_parts()/
+dnl get_part() handle every one of them by returning a clean error or
+dnl a sensible part count -- never a crash or a hang -- and that stays
+dnl true as the parser evolves.
+
+AT_SETUP([missing boundary parameter])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed
+
+--XYZ
+Content-Type: text/plain
+
+part one
+--XYZ--
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed
+get_num_parts rc=4137
+])
+AT_CLEANUP
+
+AT_SETUP([empty boundary value])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary=""
+
+part one, no real boundary anywhere
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=""
+get_num_parts rc=4137
+])
+AT_CLEANUP
+
+AT_SETUP([boundary never appears in body])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary=NOPE
+
+This body never contains the boundary string at all,
+just plain text going on and on.
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=NOPE
+nparts=0
+])
+AT_CLEANUP
+
+AT_SETUP([no closing boundary])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary=XYZ
+
+--XYZ
+Content-Type: text/plain
+
+part one, never terminated with --XYZ--
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=XYZ
+nparts=1
+ content-type=text/plain
+ body: [[]]
+])
+AT_CLEANUP
+
+AT_SETUP([truncated mid-headers])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+printf 'Content-Type: multipart/mixed; boundary=XYZ\r\n\r\n--XYZ\r\nContent-Type: text/pla' > input
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=XYZ
+nparts=1
+ content-type=text/pla
+ body: [[]]
+])
+AT_CLEANUP
+
+AT_SETUP([truncated right after boundary])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+printf 'Content-Type: multipart/mixed; boundary=XYZ\r\n\r\n--XYZ' > input
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=XYZ
+nparts=0
+])
+AT_CLEANUP
+
+AT_SETUP([empty body after headers])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+printf 'Content-Type: multipart/mixed; boundary=XYZ\r\n\r\n' > input
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=XYZ
+nparts=0
+])
+AT_CLEANUP
+
+AT_SETUP([header-only, no blank line at all])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+printf 'Content-Type: multipart/mixed; boundary=XYZ' > input
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=XYZ
+nparts=0
+])
+AT_CLEANUP
+
+AT_SETUP([nested multipart])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary=OUTER
+
+--OUTER
+Content-Type: multipart/alternative; boundary=INNER
+
+--INNER
+Content-Type: text/plain
+
+inner plain part
+--INNER
+Content-Type: text/html
+
+<p>inner html part</p>
+--INNER--
+--OUTER
+Content-Type: text/plain
+
+outer second part
+--OUTER--
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=OUTER
+nparts=2
+ content-type=multipart/alternative; boundary=INNER
+ nparts=2
+ content-type=text/plain
+ body: [[inner plain part]]
+ content-type=text/html
+ body: [[<p>inner html part</p>]]
+ content-type=text/plain
+ body: [[outer second part]]
+])
+AT_CLEANUP
+
+AT_SETUP([multipart with only a closing boundary, no parts])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary=X
+
+--X--
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=X
+nparts=0
+])
+AT_CLEANUP
+
+AT_SETUP([boundary value with regex-special characters])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary="a.b*c+d"
+
+--a.b*c+d
+Content-Type: text/plain
+
+content
+--a.b*c+d--
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary="a.b*c+d"
+nparts=1
+ content-type=text/plain
+ body: [[content]]
+])
+AT_CLEANUP
+
+AT_SETUP([many empty parts])
+AT_KEYWORDS([mimerobust multipart])
+AT_CHECK([
+tocrlf <<EOT >input
+Content-Type: multipart/mixed; boundary=X
+
+--X
+
+--X
+
+--X
+
+--X
+
+--X
+
+--X--
+EOT
+mimerobust < input
+],
+[0],
+[content-type=multipart/mixed; boundary=X
+nparts=5
+ content-type=text/plain
+ body: [[]]
+ content-type=text/plain
+ body: [[]]
+ content-type=text/plain
+ body: [[]]
+ content-type=text/plain
+ body: [[]]
+ content-type=text/plain
+ body: [[]]
+])
+AT_CLEANUP
+
+dnl Plain LF input here deliberately: this case is about
+dnl mu_message_is_multipart() correctly reporting false and the body
+dnl still being readable, not CRLF handling (covered separately by
+dnl mimeparts.at), so exact line-ending preservation through this
+dnl generated test isn't the point.
+AT_SETUP([claims multipart, is not])
+AT_KEYWORDS([mimerobust multipart])
+AT_DATA([input],[Content-Type: text/plain
+
+just a normal message
+])
+AT_CHECK([mimerobust < input],
+[0],
+[content-type=text/plain
+body: [[just a normal message
+]]
+])
+AT_CLEANUP
diff -uNr mailutils-3.21.orig/libmailutils/tests/mimerobust.c mailutils-3.21/libmailutils/tests/mimerobust.c
--- mailutils-3.21.orig/libmailutils/tests/mimerobust.c 1970-01-01 01:00:00.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/mimerobust.c 2026-08-01 19:43:55.817463561 +0200
@@ -0,0 +1,183 @@
+/* GNU Mailutils -- a suite of utilities for electronic mail
+ Copyright (C) 2026 Mario Fetka.
+
+ GNU Mailutils is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 3, or (at your option)
+ any later version.
+
+ GNU Mailutils is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with GNU Mailutils. If not, see <http://www.gnu.org/licenses/>. */
+
+/* Robustness companion to mimeparts.c: builds a message the same
+ minimal way an application embedding libmailutils typically does,
+ then walks it with mu_message_is_multipart()/mu_message_get_num_
+ parts()/mu_message_get_part(), reporting every return code and
+ part explicitly instead of using MU_ASSERT. A malformed message
+ (missing/empty boundary, truncated input, a boundary that never
+ appears, etc.) returning a clean error is an expected, valid
+ outcome here, not a failure -- only a crash or hang would be a
+ real bug, and this file exists to make either of those loud rather
+ than silent if they ever regress. */
+
+#ifdef HAVE_CONFIG_H
+# include <config.h>
+#endif
+#include <mailutils/mailutils.h>
+
+static void
+dump_part (mu_message_t msg, unsigned depth)
+{
+ mu_header_t header;
+ char *ctype = NULL;
+ int ismime = 0;
+ int rc;
+
+ rc = mu_message_get_header (msg, &header);
+ if (rc)
+ {
+ mu_printf ("%*sget_header rc=%d\n", depth * 2, "", rc);
+ return;
+ }
+ mu_header_aget_value_unfold (header, MU_HEADER_CONTENT_TYPE, &ctype);
+ mu_printf ("%*scontent-type=%s\n", depth * 2, "",
+ ctype ? ctype : "(none)");
+ free (ctype);
+
+ rc = mu_message_is_multipart (msg, &ismime);
+ if (rc)
+ {
+ mu_printf ("%*sis_multipart rc=%d\n", depth * 2, "", rc);
+ return;
+ }
+
+ if (ismime)
+ {
+ size_t nparts, i;
+ rc = mu_message_get_num_parts (msg, &nparts);
+ if (rc)
+ {
+ mu_printf ("%*sget_num_parts rc=%d\n", depth * 2, "", rc);
+ return;
+ }
+ mu_printf ("%*snparts=%lu\n", depth * 2, "", (unsigned long) nparts);
+ for (i = 1; i <= nparts; i++)
+ {
+ mu_message_t part;
+ int prc = mu_message_get_part (msg, i, &part);
+ if (prc)
+ {
+ mu_printf ("%*sget_part(%lu) rc=%d\n", depth * 2, "",
+ (unsigned long) i, prc);
+ continue;
+ }
+ dump_part (part, depth + 1);
+ }
+ return;
+ }
+
+ {
+ mu_body_t body;
+ mu_stream_t stream;
+
+ rc = mu_message_get_body (msg, &body);
+ if (rc)
+ {
+ mu_printf ("%*sget_body rc=%d\n", depth * 2, "", rc);
+ return;
+ }
+ rc = mu_body_get_streamref (body, &stream);
+ if (rc)
+ {
+ mu_printf ("%*sget_streamref rc=%d\n", depth * 2, "", rc);
+ return;
+ }
+ mu_printf ("%*sbody: [", depth * 2, "");
+ mu_stream_copy (mu_strout, stream, 0, NULL);
+ mu_printf ("]\n");
+ mu_stream_unref (stream);
+ }
+}
+
+int
+main (void)
+{
+ mu_message_t msg;
+ mu_stream_t stream, memstream;
+ mu_off_t size;
+ int rc;
+
+ mu_set_program_name ("mimerobust");
+ mu_stdstream_setup (MU_STDSTREAM_RESET_NONE);
+
+ rc = mu_memory_stream_create (&memstream, MU_STREAM_RDWR);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (memstream) rc=%d\n", rc);
+ return 2;
+ }
+ rc = mu_stream_copy (memstream, mu_strin, 0, NULL);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (copy stdin) rc=%d\n", rc);
+ return 2;
+ }
+ rc = mu_stream_size (memstream, &size);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (size) rc=%d\n", rc);
+ return 2;
+ }
+
+ {
+ char *buf = malloc (size ? size : 1);
+ size_t n = 0;
+ if (!buf)
+ {
+ mu_printf ("SETUP FAILED (malloc)\n");
+ return 2;
+ }
+ if (size)
+ {
+ rc = mu_stream_seek (memstream, 0, MU_SEEK_SET, NULL);
+ if (rc == 0)
+ rc = mu_stream_read (memstream, buf, size, &n);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (read) rc=%d\n", rc);
+ free (buf);
+ return 2;
+ }
+ }
+ rc = mu_static_memory_stream_create (&stream, buf, n);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (static stream) rc=%d\n", rc);
+ free (buf);
+ return 2;
+ }
+ }
+
+ rc = mu_message_create (&msg, NULL);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (message_create) rc=%d\n", rc);
+ return 2;
+ }
+ rc = mu_message_set_stream (msg, stream, NULL);
+ if (rc)
+ {
+ mu_printf ("SETUP FAILED (set_stream) rc=%d\n", rc);
+ return 2;
+ }
+
+ dump_part (msg, 0);
+
+ mu_message_unref (msg);
+ return 0;
+}
diff -uNr mailutils-3.21.orig/libmailutils/tests/testsuite.at mailutils-3.21/libmailutils/tests/testsuite.at
--- mailutils-3.21.orig/libmailutils/tests/testsuite.at 2026-08-01 17:13:57.312840761 +0200
+++ mailutils-3.21/libmailutils/tests/testsuite.at 2026-08-01 17:13:57.202432027 +0200
@@ -225,6 +225,7 @@
--- mailutils-3.21.orig/libmailutils/tests/testsuite.at 2025-01-02 14:36:35.000000000 +0100
+++ mailutils-3.21/libmailutils/tests/testsuite.at 2026-08-01 19:43:55.822164434 +0200
@@ -225,6 +225,8 @@
m4_include([inline-comment.at])
m4_include([hdrflt.at])
m4_include([hdrcpy.at])
+m4_include([hdrunfold.at])
+m4_include([hdrsize.at])
m4_include([linecon.at])
m4_include([htmlent.at])
m4_include([xml.at])
@@ -252,6 +253,7 @@
@@ -252,6 +254,8 @@
m4_include([modtofsaf.at])
m4_include([mimehdr.at])
+m4_include([mimeparts.at])
+m4_include([mimerobust.at])
m4_include([content-type.at])
m4_include([msgset.at])