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
141 changes: 132 additions & 9 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,6 +435,17 @@ gboolean bd_fs_vfat_check_label (const gchar *label, GError **error) {
const gchar *forbidden = "\"*/:<>?\\|";
guint n;

if (label == NULL) {
g_set_error_literal (error, BD_FS_ERROR, BD_FS_ERROR_LABEL_INVALID,
"Label cannot be NULL.");
return FALSE;
}

/* VFAT labels are stored in an 11-byte OEM codepage field. Use strlen
* (UTF-8 byte count) as a conservative upper bound; fatlabel performs
* the exact codepage-length check during iconv conversion. Keeping the
* original upstream message wording ("characters") so downstream
* consumers such as udisks that match on this string are not broken. */
if (strlen (label) > 11) {
g_set_error_literal (error, BD_FS_ERROR, BD_FS_ERROR_LABEL_INVALID,
"Label for VFAT filesystem must be at most 11 characters long.");
Expand Down Expand Up @@ -454,6 +569,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
154 changes: 154 additions & 0 deletions 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,57 @@ 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


def _cjk_label_supported():
"""Check whether fatlabel can actually write a CJK label using the
locale-derived codepage in the current environment.

This goes beyond _has_codepage(): iconv may advertise CP936 yet
fatlabel's locale-aware conversion can still fail on some hosts (e.g.
when the CJK locale is not fully functional for the running glibc/dosfstools
combination), so probe the real fatlabel behaviour before running the
end-to-end CJK tests and skip them if the environment cannot handle it.
"""
if not _has_codepage(936):
return False
img = None
fd = -1
try:
fd, img = tempfile.mkstemp(prefix="libblockdev.cjk.", suffix=".vfat")
os.close(fd)
fd = -1
# Create a small image (mkfs.vfat needs a real file with a size)
with open(img, "wb") as f:
f.truncate(8 * 1024 * 1024)
subprocess.run(["mkfs.vfat", "-F", "32", img],
capture_output=True, check=True)
env = dict(os.environ, LC_ALL="zh_CN.UTF-8")
r = subprocess.run(["fatlabel", "-c", "936", img, "测试"],
capture_output=True, env=env)
return r.returncode == 0
except Exception:
return False
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if img and os.path.exists(img):
try:
os.unlink(img)
except OSError:
pass


class VfatNoDevTestCase(FSNoDevTestCase):
pass

Expand Down Expand Up @@ -281,6 +335,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 (strlen), matching the upstream behaviour.

The error message wording ("characters") is preserved for
compatibility with downstream consumers such as udisks that match
on this string.
"""
succ = BlockDev.fs_vfat_check_label("a" * 11)
self.assertTrue(succ)

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

def test_vfat_check_label_non_ascii_accepted(self):
"""Non-ASCII labels within the byte limit are accepted.

"测试U盘" is 10 UTF-8 bytes (3 CJK chars + 1 ASCII), which fits
within the 11-byte limit regardless of locale.
"""
succ = BlockDev.fs_vfat_check_label("测试U盘")
self.assertTrue(succ)

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(_cjk_label_supported(),
"fatlabel cannot write CJK labels with codepage 936 in this environment")
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.

Comment on lines +424 to +436

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the missing boundary assertion for 6 CJK characters.

The docstring states 6 CJK characters (12 GBK bytes) "do not" fit, but the test only exercises the 5-character success case. Add the rejection case to verify the real OEM byte limit end-to-end.

✅ Proposed fix
         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)
+
+        # 6 CJK chars = 12 GBK bytes > 11 -- should fail
+        six = "测" * 6
+        with self.assertRaises(GLib.GError):
+            BlockDev.fs_vfat_set_label(self.loop_devs[0], six)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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)
# 6 CJK chars = 12 GBK bytes > 11 -- should fail
six = "测" * 6
with self.assertRaises(GLib.GError):
BlockDev.fs_vfat_set_label(self.loop_devs[0], six)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/fs_tests/vfat_test.py` around lines 424 - 436, Add a six-CJK-character
boundary case to test_vfat_set_label_max_cjk after the existing five-character
success assertions. Call BlockDev.fs_vfat_set_label with "测" multiplied by 6 and
assert the operation is rejected, preserving the existing successful
five-character and label verification checks.


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