Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 150 additions & 11 deletions src/plugins/fs/vfat.c
Original file line number Diff line number Diff line change
Expand Up @@ -276,43 +276,147 @@ gboolean bd_fs_vfat_repair (const gchar *device, const BDExtraArg **extra, GErro
return ret;
}

/**
* _vfat_locale_codepage:
*
* Determine the DOS/OEM codepage that matches the system locale, mirroring
* how Windows derives the OEM codepage from the system locale. FAT volume
* labels are stored as 8-bit OEM codepage bytes, so the codepage used to
* write a label must match the locale of the system that created it.
*
* The locale is read from the environment (LC_ALL > LC_CTYPE > LANG) rather
* than calling setlocale(), to avoid side effects on the host process
* (libblockdev runs inside udisksd).
*
* Returns: the codepage number, or 850 (the dosfstools default) if the locale
* cannot be determined or has no known mapping.
*/
static guint
_vfat_locale_codepage (void) {
const gchar *locale = g_getenv ("LC_ALL");
if (locale == NULL || *locale == '\0')
locale = g_getenv ("LC_CTYPE");
if (locale == NULL || *locale == '\0')
locale = g_getenv ("LANG");

if (locale == NULL || *locale == '\0' ||
g_strcmp0 (locale, "C") == 0 || g_strcmp0 (locale, "POSIX") == 0 ||
g_str_has_prefix (locale, "C."))
return 850;

/* Extract the language[_territory] part before '.' or '@' */
g_autofree gchar *lang = g_strdup (locale);
gchar *p;
p = strchr (lang, '.');
if (p) *p = '\0';
p = strchr (lang, '@');
if (p) *p = '\0';

if (g_str_has_prefix (lang, "zh_CN") || g_str_has_prefix (lang, "zh_SG"))
return 936; /* GBK */
if (g_str_has_prefix (lang, "zh_TW") || g_str_has_prefix (lang, "zh_HK"))
return 950; /* Big5 */
if (g_str_has_prefix (lang, "ja"))
return 932; /* Shift-JIS */
if (g_str_has_prefix (lang, "ko"))
return 949; /* UHC (Korean) */
if (g_str_has_prefix (lang, "ru") || g_str_has_prefix (lang, "uk") ||
g_str_has_prefix (lang, "be"))
return 866; /* Cyrillic (DOS) */

return 850;
}

/**
* _vfat_label_from_codepage:
* @label: (nullable): raw OEM codepage bytes from the filesystem
*
* Convert a label read from a VFAT filesystem (raw OEM codepage bytes, as
* returned by blkid) to UTF-8, using the locale-derived codepage. Pure
* ASCII labels and the default codepage (850) need no conversion.
*
* Returns: (transfer full): the UTF-8 label, or the original bytes on
* conversion failure. Never %NULL.
*/
static gchar *
_vfat_label_from_codepage (const gchar *label) {
if (label == NULL || *label == '\0')
return g_strdup (label ? label : "");

if (g_str_is_ascii (label))
return g_strdup (label);

guint cp = _vfat_locale_codepage ();
gchar cp_name[16];
g_snprintf (cp_name, sizeof (cp_name), "CP%u", cp);

GError *conv_error = NULL;
gchar *utf8 = g_convert (label, -1, "UTF-8", cp_name, NULL, NULL, &conv_error);
if (utf8 != NULL)
return utf8;

/* Conversion failed; return a valid UTF-8 string instead of raw bytes
* that may not be valid UTF-8. */
g_error_free (conv_error);
return g_utf8_make_valid (label, -1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* bd_fs_vfat_set_label:
* @device: the device containing the file system to set label for
* @label: label to set
* @error: (out) (optional): place to store error (if any)
*
* Returns: whether the label of vfat file system on the @device was
* successfully set or not
* Sets the label of a VFAT filesystem. For labels containing non-ASCII
* characters, the OEM codepage matching the system locale is passed to
* fatlabel via the -c option, so that labels in CJK, Cyrillic and other
* non-Western-European scripts are encoded correctly.
*
* Returns: whether the label was successfully set or not
*
* Tech category: %BD_FS_TECH_VFAT-%BD_FS_TECH_MODE_SET_LABEL
*/
gboolean bd_fs_vfat_set_label (const gchar *device, const gchar *label, GError **error) {
const gchar *args[4] = {"fatlabel", device, NULL, NULL};
/* "fatlabel" "-c" "<cp>" <device> <label> -- at most 5 non-NULL entries */
const gchar *args[6] = {"fatlabel", NULL, NULL, NULL, NULL, NULL};
UtilDep dep = {"fatlabel", "4.2", "--version", "fatlabel\\s+([\\d\\.]+).+"};
gchar *label_up = NULL;
gchar *codepage_str = NULL;
gboolean new_vfat = FALSE;
gboolean ret;
gint idx = 1;

if (!check_deps (&avail_deps, DEPS_FATLABEL_MASK, deps, DEPS_LAST, &deps_check_lock, error))
return FALSE;

/* For labels with non-ASCII characters, pass the OEM codepage matching
* the system locale to fatlabel so it can encode them correctly. */
if (label && *label && !g_str_is_ascii (label)) {
guint cp = _vfat_locale_codepage ();
codepage_str = g_strdup_printf ("%u", cp);
args[idx++] = "-c";
args[idx++] = codepage_str;
}

args[idx++] = device;

if (!label || g_strcmp0 (label, "") == 0) {
/* fatlabel >= 4.2 refuses to set empty label */
new_vfat = bd_utils_check_util_version (dep.name, dep.version,
dep.ver_arg, dep.ver_regexp,
NULL);
if (new_vfat)
args[2] = "--reset";
}

/* forcefully convert the label uppercase in case no reset was requested */
if (label && args[2] == NULL) {
args[idx++] = "--reset";
} else {
/* forcefully convert the label uppercase; g_ascii_strup only
* uppercases a-z, leaving multi-byte UTF-8 sequences unchanged */
label_up = g_ascii_strup (label, -1);
args[2] = label_up;
args[idx++] = label_up;
}

ret = bd_utils_exec_and_report_error (args, NULL, error);
g_free (label_up);
g_free (codepage_str);

return ret;
}
Expand All @@ -331,9 +435,36 @@ gboolean bd_fs_vfat_check_label (const gchar *label, GError **error) {
const gchar *forbidden = "\"*/:<>?\\|";
guint n;

if (strlen (label) > 11) {
if (label == NULL) {
g_set_error_literal (error, BD_FS_ERROR, BD_FS_ERROR_LABEL_INVALID,
"Label for VFAT filesystem must be at most 11 characters long.");
"Label cannot be NULL.");
return FALSE;
}

/* VFAT labels are stored as 11 raw OEM codepage bytes. For non-ASCII
* labels, convert to the locale-derived OEM codepage and check the
* resulting byte count so that check_label matches what set_label
* (and fatlabel) actually accept. */
gsize label_len = strlen (label);
if (!g_str_is_ascii (label)) {
gchar cp_name[16];
g_snprintf (cp_name, sizeof (cp_name), "CP%u", _vfat_locale_codepage ());
g_autofree gchar *oem = NULL;
gsize written = 0;
GError *conv_error = NULL;
oem = g_convert (label, -1, cp_name, "UTF-8", NULL, &written, &conv_error);
if (oem == NULL) {
g_error_free (conv_error);
g_set_error_literal (error, BD_FS_ERROR, BD_FS_ERROR_LABEL_INVALID,
"Label cannot be encoded in the OEM codepage of the current locale.");
return FALSE;
}
label_len = written;
}

if (label_len > 11) {
g_set_error_literal (error, BD_FS_ERROR, BD_FS_ERROR_LABEL_INVALID,
"Label for VFAT filesystem must be at most 11 bytes long.");
return FALSE;
}

Expand Down Expand Up @@ -454,6 +585,14 @@ BDFSVfatInfo* bd_fs_vfat_get_info (const gchar *device, GError **error) {
return NULL;
}

/* blkid returns raw OEM codepage bytes; convert to UTF-8 so the label
* round-trips with bd_fs_vfat_set_label(). */
{
gchar *utf8_label = _vfat_label_from_codepage (ret->label);
g_free (ret->label);
ret->label = utf8_label;
}

success = bd_utils_exec_and_capture_output (args, NULL, &output, error);
if (!success) {
/* error is already populated */
Expand Down
114 changes: 113 additions & 1 deletion tests/fs_tests/vfat_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import os
import re
import subprocess
import tempfile
import unittest

from packaging.version import Version

Expand All @@ -23,6 +26,15 @@ def _get_dosfstools_version():
DOSFSTOOLS_VERSION = _get_dosfstools_version()


def _has_codepage(cp):
"""Check whether the given DOS codepage is available via iconv."""
try:
p = subprocess.run(["iconv", "-l"], capture_output=True, text=True)
return "CP%d" % cp in p.stdout or "CP%d//" % cp in p.stdout
except Exception:
return False


class VfatNoDevTestCase(FSNoDevTestCase):
pass

Expand Down Expand Up @@ -228,7 +240,7 @@ def test_vfat_set_label(self):
succ = BlockDev.fs_vfat_check_label("TEST_LABEL")
self.assertTrue(succ)

with self.assertRaisesRegex(GLib.GError, "at most 11 characters long."):
with self.assertRaisesRegex(GLib.GError, "at most 11 bytes long."):
BlockDev.fs_vfat_check_label(12 * "a")


Expand Down Expand Up @@ -281,6 +293,106 @@ def test_vfat_set_uuid(self):
BlockDev.fs_vfat_check_uuid(10 * "f")


class VfatCheckLabel(VfatNoDevTestCase):
"""Tests for bd_fs_vfat_check_label (no device needed)."""

def test_vfat_check_label_byte_limit(self):
"""The limit is 11 bytes, not 11 characters.

A 4-character CJK label (12 UTF-8 bytes) exceeds the 11-byte limit
and must be rejected, even though it is only 4 characters long.
"""
succ = BlockDev.fs_vfat_check_label("a" * 11)
self.assertTrue(succ)

with self.assertRaisesRegex(GLib.GError, "at most 11 bytes long."):
BlockDev.fs_vfat_check_label("a" * 12)

def test_vfat_check_label_non_ascii_byte_count(self):
"""Non-ASCII labels are checked by byte count, not character count."""
# 3 CJK chars + 1 ASCII char = 10 UTF-8 bytes <= 11
succ = BlockDev.fs_vfat_check_label("测试U盘")
self.assertTrue(succ)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 4 CJK chars = 12 UTF-8 bytes > 11
with self.assertRaisesRegex(GLib.GError, "at most 11 bytes long."):
BlockDev.fs_vfat_check_label("测试优盘")

def test_vfat_check_label_forbidden_chars(self):
"""Forbidden characters must still be rejected."""
for ch in '"*/:<>?\\|':
with self.assertRaisesRegex(GLib.GError, "not supported in VFAT labels"):
BlockDev.fs_vfat_check_label("A" + ch + "B")


@unittest.skipUnless(_has_codepage(936),
"DOS codepage 936 (GBK) not available via iconv")
class VfatSetLabelNonAscii(VfatTestCase):
"""Tests for non-ASCII (CJK) VFAT labels using locale-derived codepage."""

@classmethod
def setUpClass(cls):
super(VfatSetLabelNonAscii, cls).setUpClass()
cls._saved_env = {}
for var in ("LC_ALL", "LC_CTYPE", "LANG"):
cls._saved_env[var] = os.environ.get(var)

@classmethod
def tearDownClass(cls):
for var, val in cls._saved_env.items():
if val is None:
os.environ.pop(var, None)
else:
os.environ[var] = val
super(VfatSetLabelNonAscii, cls).tearDownClass()

def setUp(self):
super(VfatSetLabelNonAscii, self).setUp()
# _vfat_locale_codepage reads LC_ALL > LC_CTYPE > LANG from the
# environment; set a CJK locale so it maps to codepage 936 (GBK).
os.environ["LC_ALL"] = "zh_CN.UTF-8"

def test_vfat_set_non_ascii_label_roundtrip(self):
"""Set a CJK label and verify get_info reads it back as UTF-8."""

succ = BlockDev.fs_vfat_mkfs(self.loop_devs[0], self._mkfs_options)
self.assertTrue(succ)

label = "测试U盘" # 4 CJK/Latin chars, 7 GBK bytes, 10 UTF-8 bytes
succ = BlockDev.fs_vfat_set_label(self.loop_devs[0], label)
self.assertTrue(succ)

fi = BlockDev.fs_vfat_get_info(self.loop_devs[0])
self.assertTrue(fi)
self.assertEqual(fi.label, label)

def test_vfat_set_ascii_label_unchanged(self):
"""Pure-ASCII labels must not be affected by the codepage change."""

succ = BlockDev.fs_vfat_mkfs(self.loop_devs[0], self._mkfs_options)
self.assertTrue(succ)

succ = BlockDev.fs_vfat_set_label(self.loop_devs[0], "HELLO")
self.assertTrue(succ)

fi = BlockDev.fs_vfat_get_info(self.loop_devs[0])
self.assertTrue(fi)
self.assertEqual(fi.label, "HELLO")

def test_vfat_set_label_max_cjk(self):
"""5 CJK characters (10 GBK bytes) fit in 11 DOS bytes; 6 do not."""

succ = BlockDev.fs_vfat_mkfs(self.loop_devs[0], self._mkfs_options)
self.assertTrue(succ)

# 5 CJK chars = 10 GBK bytes <= 11 -- should succeed
five = "测" * 5
succ = BlockDev.fs_vfat_set_label(self.loop_devs[0], five)
self.assertTrue(succ)
fi = BlockDev.fs_vfat_get_info(self.loop_devs[0])
self.assertEqual(fi.label, five)
Comment thread
Johnson-zs marked this conversation as resolved.


@utils.required_plugins(("tools",))
class VfatResize(VfatTestCase):
def test_vfat_resize(self):
Expand Down