[RFC PATCH 3/8] ALSA: usb-audio: don't cut the card name inside a UTF-8 character
From: Luca Rodenhäuser
Date: Tue Sep 15 2026 - 12:36:32 EST
usb_string() converts the UTF-16LE string descriptors of a device into
UTF-8, so dev->product may contain multi-byte characters. Copying it into
the 32-byte card->shortname with strscpy() cuts at a byte boundary, so a
name that does not fit can end in a partial sequence. What user space
receives is then not valid UTF-8, and it cannot repair it: the missing
bytes never left the kernel.
Add snd_utf8_strscpy(), which behaves exactly like strscpy() except that it
ends the copy after the last complete sequence that fits. A name that is
too long loses up to three more bytes than before, but what remains is a
whole character. For pure ASCII the result is byte for byte what strscpy()
produced, so nothing changes for the vast majority of devices.
If the string turns out not to be UTF-8 after all - more continuation bytes
in a row than any sequence can have - the plain strscpy() cut stands, so a
device with a broken descriptor is no worse off than today.
This is worth doing on its own, independently of any richer replacement for
the name fields: it makes the existing field correct for every reader that
is already there, without changing what those readers see for any name that
fits.
Assisted-by: LLM
Signed-off-by: Luca Rodenhäuser <otzelot2021@xxxxxxxxxx>
---
include/sound/core.h | 1 +
sound/core/init.c | 52 +++++++++++++++++++++++++++++++++++
sound/core/sound_kunit.c | 58 ++++++++++++++++++++++++++++++++++++++++
sound/usb/card.c | 7 +++--
4 files changed, 116 insertions(+), 2 deletions(-)
diff --git a/include/sound/core.h b/include/sound/core.h
index 2ca24ac7e3..36bb95044a 100644
--- a/include/sound/core.h
+++ b/include/sound/core.h
@@ -316,6 +316,7 @@ void snd_card_free(struct snd_card *card);
void snd_card_free_when_closed(struct snd_card *card);
int snd_card_free_on_error(struct device *dev, int ret);
void snd_card_set_id(struct snd_card *card, const char *id);
+size_t snd_utf8_strscpy(char *dst, const char *src, size_t size);
int snd_card_register(struct snd_card *card);
int snd_card_info_init(void);
int snd_card_add_dev_attr(struct snd_card *card,
diff --git a/sound/core/init.c b/sound/core/init.c
index 2b33dd7b42..67b79b1826 100644
--- a/sound/core/init.c
+++ b/sound/core/init.c
@@ -789,6 +789,58 @@ void snd_card_set_id(struct snd_card *card, const char *nid)
}
EXPORT_SYMBOL(snd_card_set_id);
+/**
+ * snd_utf8_strscpy - copy a UTF-8 string, cutting only at a character boundary
+ * @dst: destination buffer
+ * @src: source string, expected to be UTF-8
+ * @size: size of @dst in bytes
+ *
+ * Like strscpy(), except that a string too long for @dst ends after the last
+ * complete UTF-8 sequence that fits, so @dst never ends in the middle of a
+ * character. For pure ASCII input the result is identical to strscpy().
+ *
+ * This matters for names that come from a device: usb_string() converts the
+ * UTF-16LE string descriptors into UTF-8, so a product name copied into one
+ * of the fixed-size card name fields can otherwise end in a partial sequence,
+ * which user space has no way to repair.
+ *
+ * Return: the length of the copied string, excluding the terminating NUL.
+ */
+size_t snd_utf8_strscpy(char *dst, const char *src, size_t size)
+{
+ size_t len, tail;
+
+ if (!size)
+ return 0;
+
+ len = strnlen(src, size);
+ if (len < size) {
+ memcpy(dst, src, len);
+ dst[len] = '\0';
+ return len;
+ }
+
+ /* @src does not fit. strscpy() would cut at @size - 1, which may land
+ * inside a multi-byte sequence, so back up to the start of that
+ * sequence instead. A sequence is at most four bytes, so a longer run
+ * of continuation bytes means @src is not UTF-8 after all and the plain
+ * cut stands.
+ */
+ len = size - 1;
+ for (tail = 0; tail < 4 && len; tail++) {
+ if (((unsigned char)src[len] & 0xc0) != 0x80)
+ break;
+ len--;
+ }
+ if (tail >= 4)
+ len = size - 1;
+
+ memcpy(dst, src, len);
+ dst[len] = '\0';
+ return len;
+}
+EXPORT_SYMBOL_GPL(snd_utf8_strscpy);
+
static ssize_t id_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
diff --git a/sound/core/sound_kunit.c b/sound/core/sound_kunit.c
index 0376112cc6..d462462543 100644
--- a/sound/core/sound_kunit.c
+++ b/sound/core/sound_kunit.c
@@ -299,6 +299,63 @@ static void test_card_add_component(struct kunit *test)
KUNIT_ASSERT_STREQ(test, card->components, TEST_FIRST_COMPONENT " " TEST_SECOND_COMPONENT);
}
+/* 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.
+ */
+#define STRSCPY_UMLAUT "\xc3\xa4" /* 2 bytes */
+#define STRSCPY_HIRAGANA "\xe3\x81\x82" /* 3 bytes */
+
+static void test_utf8_strscpy(struct kunit *test)
+{
+ char dst[8], ref[8];
+ size_t len;
+
+ /* fits: same result as strscpy(), NUL terminated */
+ len = snd_utf8_strscpy(dst, "abc", sizeof(dst));
+ KUNIT_EXPECT_EQ(test, len, 3);
+ KUNIT_EXPECT_STREQ(test, dst, "abc");
+
+ /* exactly fills the buffer */
+ len = snd_utf8_strscpy(dst, "abcdefg", sizeof(dst));
+ KUNIT_EXPECT_EQ(test, len, sizeof(dst) - 1);
+ KUNIT_EXPECT_STREQ(test, dst, "abcdefg");
+
+ /* too long and pure ASCII: identical to strscpy() */
+ len = snd_utf8_strscpy(dst, "abcdefghij", sizeof(dst));
+ strscpy(ref, "abcdefghij", sizeof(ref));
+ 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
+ */
+ len = snd_utf8_strscpy(dst, STRSCPY_UMLAUT STRSCPY_UMLAUT
+ STRSCPY_UMLAUT STRSCPY_UMLAUT,
+ sizeof(dst));
+ KUNIT_EXPECT_EQ(test, len, 6);
+ KUNIT_EXPECT_STREQ(test, dst, STRSCPY_UMLAUT STRSCPY_UMLAUT
+ STRSCPY_UMLAUT);
+
+ /* 3-byte characters: two fit, the third would be cut */
+ len = snd_utf8_strscpy(dst, STRSCPY_HIRAGANA STRSCPY_HIRAGANA
+ STRSCPY_HIRAGANA,
+ sizeof(dst));
+ KUNIT_EXPECT_EQ(test, len, 6);
+ KUNIT_EXPECT_STREQ(test, dst, STRSCPY_HIRAGANA STRSCPY_HIRAGANA);
+
+ /* not UTF-8 at all: a run of continuation bytes longer than any
+ * sequence, so the plain strscpy() cut stands rather than eating the
+ * whole string
+ */
+ len = snd_utf8_strscpy(dst, "\x80\x80\x80\x80\x80\x80\x80\x80\x80",
+ sizeof(dst));
+ KUNIT_EXPECT_EQ(test, len, sizeof(dst) - 1);
+
+ /* zero-sized destination must not be written to */
+ KUNIT_EXPECT_EQ(test, snd_utf8_strscpy(dst, "abc", 0), 0);
+}
+
static struct kunit_case sound_utils_cases[] = {
KUNIT_CASE(test_phys_format_size),
KUNIT_CASE(test_format_width),
@@ -310,6 +367,7 @@ static struct kunit_case sound_utils_cases[] = {
KUNIT_CASE(test_card_set_id),
KUNIT_CASE(test_pcm_format_name),
KUNIT_CASE(test_card_add_component),
+ KUNIT_CASE(test_utf8_strscpy),
{},
};
diff --git a/sound/usb/card.c b/sound/usb/card.c
index 9307da95ef..a83c51a3d5 100644
--- a/sound/usb/card.c
+++ b/sound/usb/card.c
@@ -630,9 +630,12 @@ static void usb_audio_make_shortname(struct usb_device *dev,
return;
}
- /* retrieve the device string as shortname */
+ /* retrieve the device string as shortname. usb_string() hands back
+ * UTF-8, so cut at a character boundary rather than at a byte one.
+ */
if (dev->product && *dev->product) {
- strscpy(card->shortname, dev->product);
+ snd_utf8_strscpy(card->shortname, dev->product,
+ sizeof(card->shortname));
} else {
/* no name available from anywhere, so use ID */
scnprintf(card->shortname, sizeof(card->shortname),
--
2.43.0