[RFC PATCH 6/8] ALSA: core: add KUnit coverage for the card names

From: Luca Rodenhäuser

Date: Tue Sep 15 2026 - 12:32:05 EST


Cover what the setters promise: plain ASCII and multi-byte names survive
byte for byte, a field starts out and can be cleared back to NULL,
whitespace is stripped, a whitespace-only name counts as unset, and a name
that fits into the fixed field next to it is not stored at all.

The sanitising cases carry the weight, since they are what keeps malformed
input from reaching user space: truncated sequences, stray continuation
bytes, overlong encodings, surrogate halves, code points past U+10FFFF, C0
and C1 controls and an ANSI escape, each with the exact output expected.

test_card_full_names_passthrough pins the opposite: bidi overrides,
zero-width and tag characters and a non-character come back unchanged.
That is deliberate, and a test is the only way to keep it from looking like
an oversight later.

Truncation is checked with a 2-byte and a 3-byte character, so the case
where the limit does not divide evenly is covered, and with input that is
all bad bytes, where each one grows from one byte to three and the limit
has to hold on the output rather than the input.

test_card_id_stays_ascii is a regression test for the card id fix earlier
in this series; without it, card->id[0] comes out as 0xc3.

Assisted-by: LLM
Signed-off-by: Luca Rodenhäuser <otzelot2021@xxxxxxxxxx>
---
sound/core/sound_kunit.c | 284 ++++++++++++++++++++++++++++++++++++++-
1 file changed, 280 insertions(+), 4 deletions(-)

diff --git a/sound/core/sound_kunit.c b/sound/core/sound_kunit.c
index d462462543..26554da484 100644
--- a/sound/core/sound_kunit.c
+++ b/sound/core/sound_kunit.c
@@ -5,6 +5,7 @@
*/

#include <kunit/test.h>
+#include <linux/ctype.h>
#include <sound/core.h>
#include <sound/pcm.h>

@@ -300,8 +301,7 @@ static void test_card_add_component(struct kunit *test)
}

/* snd_utf8_strscpy() has to behave exactly like strscpy() for ASCII and only
- * differ where a multi-byte sequence would be cut. The escapes keep this file
- * plain ASCII; \x escapes are greedy, hence the split literals.
+ * differ where a multi-byte sequence would be cut.
*/
#define STRSCPY_UMLAUT "\xc3\xa4" /* 2 bytes */
#define STRSCPY_HIRAGANA "\xe3\x81\x82" /* 3 bytes */
@@ -327,8 +327,8 @@ static void test_utf8_strscpy(struct kunit *test)
KUNIT_EXPECT_EQ(test, len, sizeof(dst) - 1);
KUNIT_EXPECT_STREQ(test, dst, ref);

- /* 2-byte characters: 7 bytes of room hold three of them plus one ASCII
- * byte, so the fourth has to be dropped whole rather than halved
+ /* 2-byte characters: three fit in seven bytes, the fourth has to be
+ * dropped whole rather than halved
*/
len = snd_utf8_strscpy(dst, STRSCPY_UMLAUT STRSCPY_UMLAUT
STRSCPY_UMLAUT STRSCPY_UMLAUT,
@@ -356,6 +356,275 @@ static void test_utf8_strscpy(struct kunit *test)
KUNIT_EXPECT_EQ(test, snd_utf8_strscpy(dst, "abc", 0), 0);
}

+/* The UTF-8 test names are spelled out as escapes on purpose: the bytes are
+ * what is under test, and the file itself stays plain ASCII. Literals are
+ * split where a hex escape is followed by a hex digit, since \x escapes are
+ * greedy: "\xe2\x80\xaedef" would parse as \xe2 \x80 \xaed.
+ */
+#define UTF8_LATIN_NAME "M\xc3\xbcller Audio" /* Mueller with umlaut */
+#define UTF8_CJK_NAME "\xe3\x81\x82\xe9\x9f\xb3" /* hiragana a + kanji */
+#define UTF8_ASTRAL_NAME "\xf0\x9f\x8e\xb5 Audio" /* musical note + text */
+#define UTF8_UNTRIMMED_NAME " \t" UTF8_LATIN_NAME " \n"
+#define UTF8_ONLY_SPACES " \t\r\n "
+#define UTF8_REPLACEMENT "\xef\xbf\xbd" /* U+FFFD */
+
+#define UTF8_UMLAUT "\xc3\xa4" /* 2 bytes */
+#define UTF8_HIRAGANA "\xe3\x81\x82" /* 3 bytes */
+
+/* Malformed input. None of it is rejected any more; each bad byte becomes one
+ * U+FFFD, so that a caller is never pushed back to the unfiltered ASCII field.
+ */
+static const struct {
+ const char *in;
+ const char *out;
+} sanitised_names[] = {
+ /* truncated 2-byte start */
+ { "abc\xc3", "abc" UTF8_REPLACEMENT },
+ /* stray continuation byte */
+ { "abc\x80", "abc" UTF8_REPLACEMENT },
+ /* overlong encoding of '/': both bytes are bad */
+ { "abc\xc0\xaf", "abc" UTF8_REPLACEMENT UTF8_REPLACEMENT },
+ /* U+D800, a surrogate half: three bad bytes */
+ { "abc\xed\xa0\x80", "abc" UTF8_REPLACEMENT UTF8_REPLACEMENT
+ UTF8_REPLACEMENT },
+ /* beyond U+10FFFF */
+ { "abc\xf5\x80\x80\x80", "abc" UTF8_REPLACEMENT UTF8_REPLACEMENT
+ UTF8_REPLACEMENT UTF8_REPLACEMENT },
+ /* broken continuation: the 'A' after it is valid and survives */
+ { "abc\xe3\x81" "A", "abc" UTF8_REPLACEMENT UTF8_REPLACEMENT "A" },
+ /* C0 control in the middle becomes a space */
+ { "abc\ndef", "abc def" },
+ /* U+0085 NEL, a C1 control */
+ { "abc\xc2\x85" "def", "abc def" },
+ /* ANSI escape: the ESC is what makes it dangerous */
+ { "abc\x1b[31mdef", "abc [31mdef" },
+ /* U+2028 LINE SEPARATOR */
+ { "abc\xe2\x80\xa8" "def", "abc def" },
+};
+
+/* Code points the kernel deliberately does NOT touch. They are a real
+ * spoofing vector, but catching them needs the Unicode character properties,
+ * which live in user space. This test pins the documented behaviour so that
+ * nobody mistakes it for an oversight.
+ */
+static const char * const passthrough_names[] = {
+ "Audio\xd8\x9c" "Device", /* U+061C ARABIC LETTER MARK */
+ "Audio\xe2\x80\xae" "Device", /* U+202E RIGHT-TO-LEFT OVERRIDE */
+ "Audio\xe2\x81\xaa" "Device", /* U+206A INHIBIT SYMMETRIC SWAPPING */
+ "Au\xc2\xad" "dio", /* U+00AD SOFT HYPHEN */
+ "Audio\xef\xb8\x8f", /* U+FE0F VARIATION SELECTOR-16 */
+ "Audio\xf3\xa0\x80\x81", /* U+E0001 LANGUAGE TAG */
+ "Audio\xef\xbf\xbf", /* U+FFFF non-character */
+};
+
+/* Build @repeat copies of @seq, NUL terminated. */
+static char *utf8_repeat(struct kunit *test, const char *seq, size_t repeat)
+{
+ size_t seq_len = strlen(seq);
+ char *buf;
+ size_t i;
+
+ buf = kunit_kzalloc(test, seq_len * repeat + 1, GFP_KERNEL);
+ if (!buf)
+ return NULL;
+ for (i = 0; i < repeat; i++)
+ memcpy(buf + i * seq_len, seq, seq_len);
+ return buf;
+}
+
+static void test_card_full_names(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ /* an unset field stays NULL, which means "fall back to ASCII" */
+ KUNIT_EXPECT_NULL(test, card->full_shortname);
+ KUNIT_EXPECT_NULL(test, card->full_longname);
+ KUNIT_EXPECT_NULL(test, card->full_mixername);
+
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, UTF8_LATIN_NAME));
+ KUNIT_EXPECT_STREQ(test, card->full_shortname, UTF8_LATIN_NAME);
+
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_longname(card, UTF8_CJK_NAME));
+ KUNIT_EXPECT_STREQ(test, card->full_longname, UTF8_CJK_NAME);
+
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_mixername(card, UTF8_ASTRAL_NAME));
+ KUNIT_EXPECT_STREQ(test, card->full_mixername, UTF8_ASTRAL_NAME);
+
+ /* setting it again replaces the previous value */
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, UTF8_CJK_NAME));
+ KUNIT_EXPECT_STREQ(test, card->full_shortname, UTF8_CJK_NAME);
+
+ /* NULL clears the field back to "unset" */
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, NULL));
+ KUNIT_EXPECT_NULL(test, card->full_shortname);
+
+ snd_card_set_full_longname(card, NULL);
+ snd_card_set_full_mixername(card, NULL);
+}
+
+/* A UTF-8 field is only worth an allocation when it carries more than the
+ * ASCII field next to it; otherwise the documented fallback says the same.
+ */
+static void test_card_utf8_names_same_as_ascii(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ strscpy(card->shortname, VALID_NAME);
+
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, VALID_NAME));
+ KUNIT_EXPECT_NULL(test, card->full_shortname);
+
+ /* the same name with padding sanitises to the ASCII one, so still unset */
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, " " VALID_NAME "\t"));
+ KUNIT_EXPECT_NULL(test, card->full_shortname);
+
+ /* one that differs is kept */
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, UTF8_LATIN_NAME));
+ KUNIT_EXPECT_STREQ(test, card->full_shortname, UTF8_LATIN_NAME);
+
+ snd_card_set_full_shortname(card, NULL);
+}
+
+static void test_card_utf8_names_sanitised(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+ size_t i;
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ for (i = 0; i < ARRAY_SIZE(sanitised_names); i++) {
+ KUNIT_EXPECT_EQ_MSG(test, 0,
+ snd_card_set_full_shortname(card, sanitised_names[i].in),
+ "name %zu was rejected", i);
+ KUNIT_EXPECT_STREQ_MSG(test, card->full_shortname,
+ sanitised_names[i].out,
+ "name %zu came out wrong", i);
+ }
+
+ snd_card_set_full_shortname(card, NULL);
+}
+
+static void test_card_utf8_names_passthrough(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+ size_t i;
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ for (i = 0; i < ARRAY_SIZE(passthrough_names); i++) {
+ KUNIT_EXPECT_EQ(test, 0,
+ snd_card_set_full_shortname(card, passthrough_names[i]));
+ KUNIT_EXPECT_STREQ_MSG(test, card->full_shortname,
+ passthrough_names[i],
+ "name %zu was altered; the kernel does not filter these",
+ i);
+ }
+
+ snd_card_set_full_shortname(card, NULL);
+}
+
+static void test_card_utf8_names_trimmed(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, UTF8_UNTRIMMED_NAME));
+ KUNIT_EXPECT_STREQ(test, card->full_shortname, UTF8_LATIN_NAME);
+
+ /* a name that is only whitespace carries no information: treat it as
+ * unset rather than storing an empty string
+ */
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, UTF8_ONLY_SPACES));
+ KUNIT_EXPECT_NULL(test, card->full_shortname);
+
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, ""));
+ KUNIT_EXPECT_NULL(test, card->full_shortname);
+
+ /* control characters at the ends become spaces and are then trimmed */
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, "\x01" VALID_NAME "\x7f"));
+ KUNIT_EXPECT_STREQ(test, card->full_shortname, VALID_NAME);
+
+ snd_card_set_full_shortname(card, NULL);
+}
+
+static void test_card_utf8_names_truncated(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+ size_t len;
+ char *name;
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ /* 2-byte characters divide the limit evenly */
+ name = utf8_repeat(test, UTF8_UMLAUT, SNDRV_CARD_FULL_SHORTNAME_MAX);
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, name);
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, name));
+ KUNIT_ASSERT_NOT_NULL(test, card->full_shortname);
+ KUNIT_EXPECT_EQ(test, strlen(card->full_shortname),
+ SNDRV_CARD_FULL_SHORTNAME_MAX);
+
+ /* 3-byte characters do not: the last whole character has to win, so the
+ * result stays below the limit instead of ending inside a sequence
+ */
+ name = utf8_repeat(test, UTF8_HIRAGANA, SNDRV_CARD_FULL_SHORTNAME_MAX);
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, name);
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, name));
+ KUNIT_ASSERT_NOT_NULL(test, card->full_shortname);
+ len = strlen(card->full_shortname);
+ KUNIT_EXPECT_LE(test, len, (size_t)SNDRV_CARD_FULL_SHORTNAME_MAX);
+ KUNIT_EXPECT_EQ(test, len % strlen(UTF8_HIRAGANA), 0);
+
+ /* replacing a bad byte grows it from one byte to three, so the limit
+ * has to be enforced on the output, not on the input
+ */
+ name = utf8_repeat(test, "\x80", SNDRV_CARD_FULL_SHORTNAME_MAX);
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, name);
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_shortname(card, name));
+ KUNIT_ASSERT_NOT_NULL(test, card->full_shortname);
+ len = strlen(card->full_shortname);
+ KUNIT_EXPECT_LE(test, len, (size_t)SNDRV_CARD_FULL_SHORTNAME_MAX);
+ KUNIT_EXPECT_EQ(test, len % strlen(UTF8_REPLACEMENT), 0);
+
+ /* the longname field has its own, larger limit */
+ name = utf8_repeat(test, UTF8_UMLAUT, SNDRV_CARD_FULL_LONGNAME_MAX);
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, name);
+ KUNIT_EXPECT_EQ(test, 0, snd_card_set_full_longname(card, name));
+ KUNIT_ASSERT_NOT_NULL(test, card->full_longname);
+ KUNIT_EXPECT_EQ(test, strlen(card->full_longname),
+ SNDRV_CARD_FULL_LONGNAME_MAX);
+
+ snd_card_set_full_shortname(card, NULL);
+ snd_card_set_full_longname(card, NULL);
+}
+
+/* card->id is an identifier, not a display name: it has to stay alphanumeric
+ * ASCII even when the name it is derived from is not. isalpha() alone does
+ * not ensure that, since the ctype table treats the Latin-1 high bytes as
+ * letters.
+ */
+static void test_card_id_stays_ascii(struct kunit *test)
+{
+ struct snd_card *card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL);
+ size_t i;
+
+ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, card);
+
+ /* the digit has to be the first ASCII-safe byte to reach the path that
+ * copies the source byte unfiltered
+ */
+ snd_card_set_id(card, "\xc3\x84" "3000");
+
+ for (i = 0; card->id[i]; i++)
+ KUNIT_EXPECT_TRUE_MSG(test, isascii(card->id[i]),
+ "card->id[%zu] = 0x%02x is not ASCII",
+ i, (unsigned char)card->id[i]);
+}
+
static struct kunit_case sound_utils_cases[] = {
KUNIT_CASE(test_phys_format_size),
KUNIT_CASE(test_format_width),
@@ -368,6 +637,13 @@ static struct kunit_case sound_utils_cases[] = {
KUNIT_CASE(test_pcm_format_name),
KUNIT_CASE(test_card_add_component),
KUNIT_CASE(test_utf8_strscpy),
+ KUNIT_CASE(test_card_full_names),
+ KUNIT_CASE(test_card_utf8_names_same_as_ascii),
+ KUNIT_CASE(test_card_utf8_names_sanitised),
+ KUNIT_CASE(test_card_utf8_names_passthrough),
+ KUNIT_CASE(test_card_utf8_names_trimmed),
+ KUNIT_CASE(test_card_utf8_names_truncated),
+ KUNIT_CASE(test_card_id_stays_ascii),
{},
};

--
2.43.0