mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* [PATCH 0/3] hfsplus: fix "Illegal name" fsck failures from non-canonical decomposition
@ 2026-09-19  0:16 Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 1/3] hfsplus: add Unicode combining-class and legacy decomposition data Viacheslav Dubeyko
                   ` (2 more replies)
  0 siblings, 3 replies; 4+ messages in thread
From: Viacheslav Dubeyko @ 2026-09-19  0:16 UTC (permalink / raw)
  To: glaubitz, frank.li
  Cc: linux-fsdevel, linux-kernel, vdubeyko, Viacheslav Dubeyko

The xfstests generic/339 (fsstress-style dirhash collision testing)
leaves an HFS+ volume corrupted and, as a result, fsck.hfsplus
reports a long run of "Illegal name".

The root cause is that fs/hfsplus/unicode.c's decomposition table
(Apple Technote #1150) decomposes each source character entirely on
its own and never does anything further with the result. Real fsck_hfs
has carried a permanent "FixDecomps" fixup in CatalogCheck.c for
exactly what that leaves unhandled:

 - combining marks contributed by two different source characters
   landing adjacent must be in ascending Unicode combining-class order,
   not just input order;
 - a small, fixed set of characters either weren't decomposed at all,
   or were decomposed into a sequence Apple corrected back in Mac OS X
   10.2 ("Jaguar"), and this driver's table still predates that.

A name that violates either of these gets flagged "Illegal name" by any
real macOS system (or fsck_hfs) that later reads the volume, even
though nothing else about it is wrong.

This series makes hfsplus store, hash and compare names the same way
current macOS does:

 1/3 adds the data: a Unicode combining-class table generated from UCD
     12.1.0, and two legacy-decomposition correction tables decoded
     programmatically from hfsprogs 540.1's own DecompData.h. Pure
     data, no behavior change.

 2/3 adds the logic that uses that data: canonical reordering of
     decomposed names, a fallback to the legacy decomposition table,
     and legacy sequence substitution, all applied in hfsplus_asc2uni()
     so this is what actually gets stored. hfsplus_hash_dentry() and
     hfsplus_compare_dentry() are reworked to canonicalize names the
     same way, so a lookup can't disagree with what a create() of the
     same name actually wrote.

 3/3 adds KUnit coverage for both: reordering, stability of equal-class
     marks, the legacy decomposition and sequence fixups, and that
     hashing/comparison agree across differently-typed-but-equivalent
     spellings of the same name.

Viacheslav Dubeyko (3):
  hfsplus: add Unicode combining-class and legacy decomposition data
  hfsplus: canonicalize decomposed catalog names like fsck_hfs expects
  hfsplus: add KUnit coverage for canonical reordering and legacy fixups

 fs/hfsplus/hfsplus_fs.h   |   6 +-
 fs/hfsplus/tables.c       | 496 ++++++++++++++++++++++++++++++++++++++
 fs/hfsplus/unicode.c      | 390 ++++++++++++++++++++----------
 fs/hfsplus/unicode.h      |  63 +++++
 fs/hfsplus/unicode_test.c | 309 ++++++++++++++++++++++++
 5 files changed, 1127 insertions(+), 137 deletions(-)
 create mode 100644 fs/hfsplus/unicode.h

-- 
2.43.0


^ permalink raw reply	[flat|nested] 4+ messages in thread

* [PATCH 1/3] hfsplus: add Unicode combining-class and legacy decomposition data
  2026-09-19  0:16 [PATCH 0/3] hfsplus: fix "Illegal name" fsck failures from non-canonical decomposition Viacheslav Dubeyko
@ 2026-09-19  0:16 ` Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 2/3] hfsplus: canonicalize decomposed catalog names like fsck_hfs expects Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 3/3] hfsplus: add KUnit coverage for canonical reordering and legacy fixups Viacheslav Dubeyko
  2 siblings, 0 replies; 4+ messages in thread
From: Viacheslav Dubeyko @ 2026-09-19  0:16 UTC (permalink / raw)
  To: glaubitz, frank.li
  Cc: linux-fsdevel, linux-kernel, vdubeyko, Viacheslav Dubeyko

macOS's own fsck_hfs rejects a catalog name as "Illegal name" in two
cases this driver's decomposition tables don't currently account for:

 - a decomposed name whose combining marks aren't in Unicode canonical
   combining-class order (fsck_hfs's FixDecomps(), CatalogCheck.c);
 - a small, fixed set of characters whose canonical decomposition either
   wasn't yet defined, or was later corrected, since the driver's
   existing hfsplus_decompose_table (Apple Technote #1150) predates
   those corrections, which macOS has applied since Mac OS X 10.2
   ("Jaguar", 2002).

This is purely a data patch and adds no new behavior on its own:

 - hfsplus_ccc_table: canonical combining class per BMP code point,
   generated from the Unicode Character Database version 12.1.0
   (https://www.unicode.org/Public/12.1.0/ucd/extracted/
   DerivedCombiningClass.txt), the same UCD version fs/unicode's
   README.utf8data documents.

 - hfsplus_legacy_decomp_table and hfsplus_legacy_seq_fixups: the
   corrected decompositions and sequence replacements fsck_hfs itself
   requires, decoded from hfsprogs 540.1's
   fsck_hfs.tproj/dfalib/DecompData.h (upstream: Apple's diskdev_cmds,
   https://opensource.apple.com/source/diskdev_cmds/).

The three new struct types and the extern declarations for all of
tables.c's tables (existing and new) move into a new fs/hfsplus/
unicode.h, replacing the ad hoc externs that used to live in
hfsplus_fs.h.

Signed-off-by: Viacheslav Dubeyko <slava@dubeyko.com>
cc: John Paul Adrian Glaubitz <glaubitz@physik.fu-berlin.de>
cc: Yangtao Li <frank.li@vivo.com>
cc: linux-fsdevel@vger.kernel.org
---
 fs/hfsplus/hfsplus_fs.h   |   6 +-
 fs/hfsplus/tables.c       | 397 ++++++++++++++++++++++++++++++++++++++
 fs/hfsplus/unicode.c      |   1 +
 fs/hfsplus/unicode.h      |  59 ++++++
 fs/hfsplus/unicode_test.c |   1 +
 5 files changed, 459 insertions(+), 5 deletions(-)
 create mode 100644 fs/hfsplus/unicode.h

diff --git a/fs/hfsplus/hfsplus_fs.h b/fs/hfsplus/hfsplus_fs.h
index 1e5b58e6a13f..916e6552e3f0 100644
--- a/fs/hfsplus/hfsplus_fs.h
+++ b/fs/hfsplus/hfsplus_fs.h
@@ -17,6 +17,7 @@
 #include <linux/blkdev.h>
 #include <linux/fs_context.h>
 #include "hfsplus_raw.h"
+#include "unicode.h"
 
 /* Runtime config options */
 #define HFSPLUS_DEF_CR_TYPE    0x3F3F3F3F  /* '????' */
@@ -486,11 +487,6 @@ void hfsplus_mark_mdb_dirty(struct super_block *sb);
 void hfsplus_prepare_volume_header_for_commit(struct hfsplus_vh *vhdr);
 int hfsplus_commit_superblock(struct super_block *sb);
 
-/* tables.c */
-extern u16 hfsplus_case_fold_table[];
-extern u16 hfsplus_decompose_table[];
-extern u16 hfsplus_compose_table[];
-
 /* unicode.c */
 int hfsplus_strcasecmp(const struct hfsplus_unistr *s1,
 		       const struct hfsplus_unistr *s2);
diff --git a/fs/hfsplus/tables.c b/fs/hfsplus/tables.c
index a5fb8ee7d019..3bbdc83debb5 100644
--- a/fs/hfsplus/tables.c
+++ b/fs/hfsplus/tables.c
@@ -3244,3 +3244,400 @@ u16 hfsplus_compose_table[] = {
 	/* 0x0342 0x0314 0x03c9 0x0345 */
 	0x1fa7, 0x0000,
 };
+
+/*
+ * Unicode canonical combining class (ccc) table, restricted to the Basic
+ * Multilingual Plane: HFS+ names are stored as UTF-16 and this kernel's
+ * wchar_t (see MAX_WCHAR_T in include/linux/nls.h) tops out at U+FFFF, so
+ * no HFS+ catalog name can contain a code point above the BMP anyway.
+ *
+ * Generated from the Unicode Character Database, version 12.1.0
+ * (https://www.unicode.org/Public/12.1.0/ucd/extracted/DerivedCombiningClass.txt),
+ * the same UCD version fs/unicode/README.utf8data documents. Class 0
+ * ("Not_Reordered") is the default for every code point not listed here
+ * and needs no entry.
+ *
+ * Used by hfsplus_canonical_reorder() to apply the Unicode Canonical
+ * Ordering Algorithm to a decomposed name, matching what macOS's own
+ * fsck_hfs (FixDecomps()) requires of a stored catalog name.
+ */
+struct hfsplus_ccc_range hfsplus_ccc_table[] = {
+	{ 0x0300, 0x0314, 230 },
+	{ 0x0315, 0x0315, 232 },
+	{ 0x0316, 0x0319, 220 },
+	{ 0x031a, 0x031a, 232 },
+	{ 0x031b, 0x031b, 216 },
+	{ 0x031c, 0x0320, 220 },
+	{ 0x0321, 0x0322, 202 },
+	{ 0x0323, 0x0326, 220 },
+	{ 0x0327, 0x0328, 202 },
+	{ 0x0329, 0x0333, 220 },
+	{ 0x0334, 0x0338,   1 },
+	{ 0x0339, 0x033c, 220 },
+	{ 0x033d, 0x0344, 230 },
+	{ 0x0345, 0x0345, 240 },
+	{ 0x0346, 0x0346, 230 },
+	{ 0x0347, 0x0349, 220 },
+	{ 0x034a, 0x034c, 230 },
+	{ 0x034d, 0x034e, 220 },
+	{ 0x0350, 0x0352, 230 },
+	{ 0x0353, 0x0356, 220 },
+	{ 0x0357, 0x0357, 230 },
+	{ 0x0358, 0x0358, 232 },
+	{ 0x0359, 0x035a, 220 },
+	{ 0x035b, 0x035b, 230 },
+	{ 0x035c, 0x035c, 233 },
+	{ 0x035d, 0x035e, 234 },
+	{ 0x035f, 0x035f, 233 },
+	{ 0x0360, 0x0361, 234 },
+	{ 0x0362, 0x0362, 233 },
+	{ 0x0363, 0x036f, 230 },
+	{ 0x0483, 0x0487, 230 },
+	{ 0x0591, 0x0591, 220 },
+	{ 0x0592, 0x0595, 230 },
+	{ 0x0596, 0x0596, 220 },
+	{ 0x0597, 0x0599, 230 },
+	{ 0x059a, 0x059a, 222 },
+	{ 0x059b, 0x059b, 220 },
+	{ 0x059c, 0x05a1, 230 },
+	{ 0x05a2, 0x05a7, 220 },
+	{ 0x05a8, 0x05a9, 230 },
+	{ 0x05aa, 0x05aa, 220 },
+	{ 0x05ab, 0x05ac, 230 },
+	{ 0x05ad, 0x05ad, 222 },
+	{ 0x05ae, 0x05ae, 228 },
+	{ 0x05af, 0x05af, 230 },
+	{ 0x05b0, 0x05b0,  10 },
+	{ 0x05b1, 0x05b1,  11 },
+	{ 0x05b2, 0x05b2,  12 },
+	{ 0x05b3, 0x05b3,  13 },
+	{ 0x05b4, 0x05b4,  14 },
+	{ 0x05b5, 0x05b5,  15 },
+	{ 0x05b6, 0x05b6,  16 },
+	{ 0x05b7, 0x05b7,  17 },
+	{ 0x05b8, 0x05b8,  18 },
+	{ 0x05b9, 0x05ba,  19 },
+	{ 0x05bb, 0x05bb,  20 },
+	{ 0x05bc, 0x05bc,  21 },
+	{ 0x05bd, 0x05bd,  22 },
+	{ 0x05bf, 0x05bf,  23 },
+	{ 0x05c1, 0x05c1,  24 },
+	{ 0x05c2, 0x05c2,  25 },
+	{ 0x05c4, 0x05c4, 230 },
+	{ 0x05c5, 0x05c5, 220 },
+	{ 0x05c7, 0x05c7,  18 },
+	{ 0x0610, 0x0617, 230 },
+	{ 0x0618, 0x0618,  30 },
+	{ 0x0619, 0x0619,  31 },
+	{ 0x061a, 0x061a,  32 },
+	{ 0x064b, 0x064b,  27 },
+	{ 0x064c, 0x064c,  28 },
+	{ 0x064d, 0x064d,  29 },
+	{ 0x064e, 0x064e,  30 },
+	{ 0x064f, 0x064f,  31 },
+	{ 0x0650, 0x0650,  32 },
+	{ 0x0651, 0x0651,  33 },
+	{ 0x0652, 0x0652,  34 },
+	{ 0x0653, 0x0654, 230 },
+	{ 0x0655, 0x0656, 220 },
+	{ 0x0657, 0x065b, 230 },
+	{ 0x065c, 0x065c, 220 },
+	{ 0x065d, 0x065e, 230 },
+	{ 0x065f, 0x065f, 220 },
+	{ 0x0670, 0x0670,  35 },
+	{ 0x06d6, 0x06dc, 230 },
+	{ 0x06df, 0x06e2, 230 },
+	{ 0x06e3, 0x06e3, 220 },
+	{ 0x06e4, 0x06e4, 230 },
+	{ 0x06e7, 0x06e8, 230 },
+	{ 0x06ea, 0x06ea, 220 },
+	{ 0x06eb, 0x06ec, 230 },
+	{ 0x06ed, 0x06ed, 220 },
+	{ 0x0711, 0x0711,  36 },
+	{ 0x0730, 0x0730, 230 },
+	{ 0x0731, 0x0731, 220 },
+	{ 0x0732, 0x0733, 230 },
+	{ 0x0734, 0x0734, 220 },
+	{ 0x0735, 0x0736, 230 },
+	{ 0x0737, 0x0739, 220 },
+	{ 0x073a, 0x073a, 230 },
+	{ 0x073b, 0x073c, 220 },
+	{ 0x073d, 0x073d, 230 },
+	{ 0x073e, 0x073e, 220 },
+	{ 0x073f, 0x0741, 230 },
+	{ 0x0742, 0x0742, 220 },
+	{ 0x0743, 0x0743, 230 },
+	{ 0x0744, 0x0744, 220 },
+	{ 0x0745, 0x0745, 230 },
+	{ 0x0746, 0x0746, 220 },
+	{ 0x0747, 0x0747, 230 },
+	{ 0x0748, 0x0748, 220 },
+	{ 0x0749, 0x074a, 230 },
+	{ 0x07eb, 0x07f1, 230 },
+	{ 0x07f2, 0x07f2, 220 },
+	{ 0x07f3, 0x07f3, 230 },
+	{ 0x07fd, 0x07fd, 220 },
+	{ 0x0816, 0x0819, 230 },
+	{ 0x081b, 0x0823, 230 },
+	{ 0x0825, 0x0827, 230 },
+	{ 0x0829, 0x082d, 230 },
+	{ 0x0859, 0x085b, 220 },
+	{ 0x08d3, 0x08d3, 220 },
+	{ 0x08d4, 0x08e1, 230 },
+	{ 0x08e3, 0x08e3, 220 },
+	{ 0x08e4, 0x08e5, 230 },
+	{ 0x08e6, 0x08e6, 220 },
+	{ 0x08e7, 0x08e8, 230 },
+	{ 0x08e9, 0x08e9, 220 },
+	{ 0x08ea, 0x08ec, 230 },
+	{ 0x08ed, 0x08ef, 220 },
+	{ 0x08f0, 0x08f0,  27 },
+	{ 0x08f1, 0x08f1,  28 },
+	{ 0x08f2, 0x08f2,  29 },
+	{ 0x08f3, 0x08f5, 230 },
+	{ 0x08f6, 0x08f6, 220 },
+	{ 0x08f7, 0x08f8, 230 },
+	{ 0x08f9, 0x08fa, 220 },
+	{ 0x08fb, 0x08ff, 230 },
+	{ 0x093c, 0x093c,   7 },
+	{ 0x094d, 0x094d,   9 },
+	{ 0x0951, 0x0951, 230 },
+	{ 0x0952, 0x0952, 220 },
+	{ 0x0953, 0x0954, 230 },
+	{ 0x09bc, 0x09bc,   7 },
+	{ 0x09cd, 0x09cd,   9 },
+	{ 0x09fe, 0x09fe, 230 },
+	{ 0x0a3c, 0x0a3c,   7 },
+	{ 0x0a4d, 0x0a4d,   9 },
+	{ 0x0abc, 0x0abc,   7 },
+	{ 0x0acd, 0x0acd,   9 },
+	{ 0x0b3c, 0x0b3c,   7 },
+	{ 0x0b4d, 0x0b4d,   9 },
+	{ 0x0bcd, 0x0bcd,   9 },
+	{ 0x0c4d, 0x0c4d,   9 },
+	{ 0x0c55, 0x0c55,  84 },
+	{ 0x0c56, 0x0c56,  91 },
+	{ 0x0cbc, 0x0cbc,   7 },
+	{ 0x0ccd, 0x0ccd,   9 },
+	{ 0x0d3b, 0x0d3c,   9 },
+	{ 0x0d4d, 0x0d4d,   9 },
+	{ 0x0dca, 0x0dca,   9 },
+	{ 0x0e38, 0x0e39, 103 },
+	{ 0x0e3a, 0x0e3a,   9 },
+	{ 0x0e48, 0x0e4b, 107 },
+	{ 0x0eb8, 0x0eb9, 118 },
+	{ 0x0eba, 0x0eba,   9 },
+	{ 0x0ec8, 0x0ecb, 122 },
+	{ 0x0f18, 0x0f19, 220 },
+	{ 0x0f35, 0x0f35, 220 },
+	{ 0x0f37, 0x0f37, 220 },
+	{ 0x0f39, 0x0f39, 216 },
+	{ 0x0f71, 0x0f71, 129 },
+	{ 0x0f72, 0x0f72, 130 },
+	{ 0x0f74, 0x0f74, 132 },
+	{ 0x0f7a, 0x0f7d, 130 },
+	{ 0x0f80, 0x0f80, 130 },
+	{ 0x0f82, 0x0f83, 230 },
+	{ 0x0f84, 0x0f84,   9 },
+	{ 0x0f86, 0x0f87, 230 },
+	{ 0x0fc6, 0x0fc6, 220 },
+	{ 0x1037, 0x1037,   7 },
+	{ 0x1039, 0x103a,   9 },
+	{ 0x108d, 0x108d, 220 },
+	{ 0x135d, 0x135f, 230 },
+	{ 0x1714, 0x1714,   9 },
+	{ 0x1734, 0x1734,   9 },
+	{ 0x17d2, 0x17d2,   9 },
+	{ 0x17dd, 0x17dd, 230 },
+	{ 0x18a9, 0x18a9, 228 },
+	{ 0x1939, 0x1939, 222 },
+	{ 0x193a, 0x193a, 230 },
+	{ 0x193b, 0x193b, 220 },
+	{ 0x1a17, 0x1a17, 230 },
+	{ 0x1a18, 0x1a18, 220 },
+	{ 0x1a60, 0x1a60,   9 },
+	{ 0x1a75, 0x1a7c, 230 },
+	{ 0x1a7f, 0x1a7f, 220 },
+	{ 0x1ab0, 0x1ab4, 230 },
+	{ 0x1ab5, 0x1aba, 220 },
+	{ 0x1abb, 0x1abc, 230 },
+	{ 0x1abd, 0x1abd, 220 },
+	{ 0x1b34, 0x1b34,   7 },
+	{ 0x1b44, 0x1b44,   9 },
+	{ 0x1b6b, 0x1b6b, 230 },
+	{ 0x1b6c, 0x1b6c, 220 },
+	{ 0x1b6d, 0x1b73, 230 },
+	{ 0x1baa, 0x1baa,   9 },
+	{ 0x1bab, 0x1bab,   9 },
+	{ 0x1be6, 0x1be6,   7 },
+	{ 0x1bf2, 0x1bf3,   9 },
+	{ 0x1c37, 0x1c37,   7 },
+	{ 0x1cd0, 0x1cd2, 230 },
+	{ 0x1cd4, 0x1cd4,   1 },
+	{ 0x1cd5, 0x1cd9, 220 },
+	{ 0x1cda, 0x1cdb, 230 },
+	{ 0x1cdc, 0x1cdf, 220 },
+	{ 0x1ce0, 0x1ce0, 230 },
+	{ 0x1ce2, 0x1ce8,   1 },
+	{ 0x1ced, 0x1ced, 220 },
+	{ 0x1cf4, 0x1cf4, 230 },
+	{ 0x1cf8, 0x1cf9, 230 },
+	{ 0x1dc0, 0x1dc1, 230 },
+	{ 0x1dc2, 0x1dc2, 220 },
+	{ 0x1dc3, 0x1dc9, 230 },
+	{ 0x1dca, 0x1dca, 220 },
+	{ 0x1dcb, 0x1dcc, 230 },
+	{ 0x1dcd, 0x1dcd, 234 },
+	{ 0x1dce, 0x1dce, 214 },
+	{ 0x1dcf, 0x1dcf, 220 },
+	{ 0x1dd0, 0x1dd0, 202 },
+	{ 0x1dd1, 0x1df5, 230 },
+	{ 0x1df6, 0x1df6, 232 },
+	{ 0x1df7, 0x1df8, 228 },
+	{ 0x1df9, 0x1df9, 220 },
+	{ 0x1dfb, 0x1dfb, 230 },
+	{ 0x1dfc, 0x1dfc, 233 },
+	{ 0x1dfd, 0x1dfd, 220 },
+	{ 0x1dfe, 0x1dfe, 230 },
+	{ 0x1dff, 0x1dff, 220 },
+	{ 0x20d0, 0x20d1, 230 },
+	{ 0x20d2, 0x20d3,   1 },
+	{ 0x20d4, 0x20d7, 230 },
+	{ 0x20d8, 0x20da,   1 },
+	{ 0x20db, 0x20dc, 230 },
+	{ 0x20e1, 0x20e1, 230 },
+	{ 0x20e5, 0x20e6,   1 },
+	{ 0x20e7, 0x20e7, 230 },
+	{ 0x20e8, 0x20e8, 220 },
+	{ 0x20e9, 0x20e9, 230 },
+	{ 0x20ea, 0x20eb,   1 },
+	{ 0x20ec, 0x20ef, 220 },
+	{ 0x20f0, 0x20f0, 230 },
+	{ 0x2cef, 0x2cf1, 230 },
+	{ 0x2d7f, 0x2d7f,   9 },
+	{ 0x2de0, 0x2dff, 230 },
+	{ 0x302a, 0x302a, 218 },
+	{ 0x302b, 0x302b, 228 },
+	{ 0x302c, 0x302c, 232 },
+	{ 0x302d, 0x302d, 222 },
+	{ 0x302e, 0x302f, 224 },
+	{ 0x3099, 0x309a,   8 },
+	{ 0xa66f, 0xa66f, 230 },
+	{ 0xa674, 0xa67d, 230 },
+	{ 0xa69e, 0xa69f, 230 },
+	{ 0xa6f0, 0xa6f1, 230 },
+	{ 0xa806, 0xa806,   9 },
+	{ 0xa8c4, 0xa8c4,   9 },
+	{ 0xa8e0, 0xa8f1, 230 },
+	{ 0xa92b, 0xa92d, 220 },
+	{ 0xa953, 0xa953,   9 },
+	{ 0xa9b3, 0xa9b3,   7 },
+	{ 0xa9c0, 0xa9c0,   9 },
+	{ 0xaab0, 0xaab0, 230 },
+	{ 0xaab2, 0xaab3, 230 },
+	{ 0xaab4, 0xaab4, 220 },
+	{ 0xaab7, 0xaab8, 230 },
+	{ 0xaabe, 0xaabf, 230 },
+	{ 0xaac1, 0xaac1, 230 },
+	{ 0xaaf6, 0xaaf6,   9 },
+	{ 0xabed, 0xabed,   9 },
+	{ 0xfb1e, 0xfb1e,  26 },
+	{ 0xfe20, 0xfe26, 230 },
+	{ 0xfe27, 0xfe2d, 220 },
+	{ 0xfe2e, 0xfe2f, 230 },
+};
+
+/*
+ * Some HFS Plus catalog names contain characters whose canonical
+ * decomposition either was not yet defined, or was later corrected, in
+ * the version of Unicode this file's tables above were generated from
+ * (Apple Technote #1150, itself derived from pre-Unicode-3.0/pre-"Jaguar"
+ * (Mac OS X 10.2) data). macOS's own fsck_hfs has carried a permanent
+ * "FixDecomps" fixup for exactly this since 2002 (see
+ * fsck_hfs.tproj/dfalib/CatalogCheck.c and DecompData.h in the hfsprogs /
+ * diskdev_cmds sources) and flags any catalog name it finds still using
+ * the old form as "Illegal name". The two tables and functions below are
+ * a port of that same fixup data, decoded from hfsprogs 540.1's
+ * DecompData.h, so this driver stores names the same way macOS has since
+ * 10.2 rather than the way the original HFS Plus format documentation
+ * described in 2001.
+ */
+
+/* Category A: single code point -> missing canonical decomposition */
+/* 44 entries */
+struct hfsplus_legacy_decomp hfsplus_legacy_decomp_table[] = {
+	{ 0x01f8, 2, { 0x004e, 0x0300 } },
+	{ 0x01f9, 2, { 0x006e, 0x0300 } },
+	{ 0x0218, 2, { 0x0053, 0x0326 } },
+	{ 0x0219, 2, { 0x0073, 0x0326 } },
+	{ 0x021a, 2, { 0x0054, 0x0326 } },
+	{ 0x021b, 2, { 0x0074, 0x0326 } },
+	{ 0x021e, 2, { 0x0048, 0x030c } },
+	{ 0x021f, 2, { 0x0068, 0x030c } },
+	{ 0x0226, 2, { 0x0041, 0x0307 } },
+	{ 0x0227, 2, { 0x0061, 0x0307 } },
+	{ 0x0228, 2, { 0x0045, 0x0327 } },
+	{ 0x0229, 2, { 0x0065, 0x0327 } },
+	{ 0x022a, 3, { 0x004f, 0x0308, 0x0304 } },
+	{ 0x022b, 3, { 0x006f, 0x0308, 0x0304 } },
+	{ 0x022c, 3, { 0x004f, 0x0303, 0x0304 } },
+	{ 0x022d, 3, { 0x006f, 0x0303, 0x0304 } },
+	{ 0x022e, 2, { 0x004f, 0x0307 } },
+	{ 0x022f, 2, { 0x006f, 0x0307 } },
+	{ 0x0230, 3, { 0x004f, 0x0307, 0x0304 } },
+	{ 0x0231, 3, { 0x006f, 0x0307, 0x0304 } },
+	{ 0x0232, 2, { 0x0059, 0x0304 } },
+	{ 0x0233, 2, { 0x0079, 0x0304 } },
+	{ 0x0400, 2, { 0x0415, 0x0300 } },
+	{ 0x040d, 2, { 0x0418, 0x0300 } },
+	{ 0x0450, 2, { 0x0435, 0x0300 } },
+	{ 0x045d, 2, { 0x0438, 0x0300 } },
+	{ 0x04ec, 2, { 0x042d, 0x0308 } },
+	{ 0x04ed, 2, { 0x044d, 0x0308 } },
+	{ 0x0622, 2, { 0x0627, 0x0653 } },
+	{ 0x0623, 2, { 0x0627, 0x0654 } },
+	{ 0x0624, 2, { 0x0648, 0x0654 } },
+	{ 0x0625, 2, { 0x0627, 0x0655 } },
+	{ 0x0626, 2, { 0x064a, 0x0654 } },
+	{ 0x06c0, 2, { 0x06d5, 0x0654 } },
+	{ 0x06c2, 2, { 0x06c1, 0x0654 } },
+	{ 0x06d3, 2, { 0x06d2, 0x0654 } },
+	{ 0x0a33, 2, { 0x0a32, 0x0a3c } },
+	{ 0x0a36, 2, { 0x0a38, 0x0a3c } },
+	{ 0x0dda, 2, { 0x0dd9, 0x0dca } },
+	{ 0x0ddc, 2, { 0x0dd9, 0x0dcf } },
+	{ 0x0ddd, 3, { 0x0dd9, 0x0dcf, 0x0dca } },
+	{ 0x0dde, 2, { 0x0dd9, 0x0ddf } },
+	{ 0x1026, 2, { 0x1025, 0x102e } },
+	{ 0xfb1d, 2, { 0x05d9, 0x05b4 } },
+};
+
+/* Category B: 2-3 code point sequence -> corrected replacement */
+/* 25 entries */
+struct hfsplus_legacy_seq_fixup hfsplus_legacy_seq_fixups[] = {
+	{ 2, { 0x00a8, 0x030d }, 2, { 0x00a8, 0x0301 } },
+	{ 2, { 0x0306, 0x0307 }, 1, { 0x0310 } },
+	{ 2, { 0x0308, 0x030d }, 2, { 0x0308, 0x0301 } },
+	{ 2, { 0x0391, 0x030d }, 2, { 0x0391, 0x0301 } },
+	{ 2, { 0x0395, 0x030d }, 2, { 0x0395, 0x0301 } },
+	{ 2, { 0x0397, 0x030d }, 2, { 0x0397, 0x0301 } },
+	{ 2, { 0x0399, 0x030d }, 2, { 0x0399, 0x0301 } },
+	{ 2, { 0x039f, 0x030d }, 2, { 0x039f, 0x0301 } },
+	{ 2, { 0x03a5, 0x030d }, 2, { 0x03a5, 0x0301 } },
+	{ 2, { 0x03a9, 0x030d }, 2, { 0x03a9, 0x0301 } },
+	{ 2, { 0x03b1, 0x030d }, 2, { 0x03b1, 0x0301 } },
+	{ 2, { 0x03b5, 0x030d }, 2, { 0x03b5, 0x0301 } },
+	{ 2, { 0x03b7, 0x030d }, 2, { 0x03b7, 0x0301 } },
+	{ 2, { 0x03b9, 0x030d }, 2, { 0x03b9, 0x0301 } },
+	{ 2, { 0x03bf, 0x030d }, 2, { 0x03bf, 0x0301 } },
+	{ 2, { 0x03c5, 0x030d }, 2, { 0x03c5, 0x0301 } },
+	{ 2, { 0x03c9, 0x030d }, 2, { 0x03c9, 0x0301 } },
+	{ 2, { 0x03d2, 0x030d }, 2, { 0x03d2, 0x0301 } },
+	{ 2, { 0x09ac, 0x09bc }, 1, { 0x09b0 } },
+	{ 2, { 0x0a21, 0x0a3c }, 1, { 0x0a5c } },
+	{ 2, { 0x0b2f, 0x0b3c }, 1, { 0x0b5f } },
+	{ 2, { 0x0e4d, 0x0e32 }, 1, { 0x0e33 } },
+	{ 2, { 0x0ecd, 0x0eb2 }, 1, { 0x0eb3 } },
+	{ 3, { 0x0fb2, 0x0f80, 0x0f71 }, 1, { 0x0f77 } },
+	{ 3, { 0x0fb3, 0x0f80, 0x0f71 }, 1, { 0x0f79 } },
+};
diff --git a/fs/hfsplus/unicode.c b/fs/hfsplus/unicode.c
index 008fec186382..93d39481e477 100644
--- a/fs/hfsplus/unicode.c
+++ b/fs/hfsplus/unicode.c
@@ -16,6 +16,7 @@
 
 #include "hfsplus_fs.h"
 #include "hfsplus_raw.h"
+#include "unicode.h"
 
 /* Fold the case of a unicode char, given the 16 bit value */
 /* Returns folded char, or 0 if ignorable */
diff --git a/fs/hfsplus/unicode.h b/fs/hfsplus/unicode.h
new file mode 100644
index 000000000000..3957ebccc232
--- /dev/null
+++ b/fs/hfsplus/unicode.h
@@ -0,0 +1,59 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Unicode related declarations
+ */
+
+#ifndef _LINUX_HFSPLUS_UNICODE_H
+#define _LINUX_HFSPLUS_UNICODE_H
+
+/*
+ * struct hfsplus_ccc_range - one run of consecutive BMP code points that
+ *                            share the same Unicode canonical combining class
+ * @first: the first UTF-16 code point in the run
+ * @last: the last code point in the run (inclusive)
+ * @combining_class: canonical combining class (ccc) shared by @first..@last
+ */
+struct hfsplus_ccc_range {
+	u16 first;
+	u16 last;
+	u8 combining_class;
+};
+
+/*
+ * struct hfsplus_legacy_decomp - corrected canonical decomposition
+ * @uc: the code point (a UTF-16 code unit) to look up
+ * @len: number of valid code units in @repl
+ * @repl: corrected decomposition sequence (base letter + combining marks)
+ */
+struct hfsplus_legacy_decomp {
+	u16 uc;
+	u8 len;
+#define HFSPLUS_LEGACY_DECOMP_MAX_LEN	(3)
+	u16 repl[HFSPLUS_LEGACY_DECOMP_MAX_LEN];
+};
+
+/*
+ * struct hfsplus_legacy_seq_fixup - corrected replacement for a short
+ *                                   sequence of already-decomposed code units
+ * @match_len: number of valid code units in @match
+ * @match: decomposed sequence to look for in the buffer
+ * @repl_len: number of valid code units in @repl
+ * @repl: corrected sequence to substitute in place of @match
+ */
+struct hfsplus_legacy_seq_fixup {
+	u8 match_len;
+#define HFSPLUS_LEGACY_SEQ_MAX_MATCH	(3)
+	u16 match[HFSPLUS_LEGACY_SEQ_MAX_MATCH];
+	u8 repl_len;
+#define HFSPLUS_LEGACY_SEQ_MAX_REPL	(2)
+	u16 repl[HFSPLUS_LEGACY_SEQ_MAX_REPL];
+};
+
+extern u16 hfsplus_case_fold_table[];
+extern u16 hfsplus_decompose_table[];
+extern u16 hfsplus_compose_table[];
+extern struct hfsplus_ccc_range hfsplus_ccc_table[];
+extern struct hfsplus_legacy_decomp hfsplus_legacy_decomp_table[];
+extern struct hfsplus_legacy_seq_fixup hfsplus_legacy_seq_fixups[];
+
+#endif /* _LINUX_HFSPLUS_UNICODE_H */
diff --git a/fs/hfsplus/unicode_test.c b/fs/hfsplus/unicode_test.c
index 7f6b2a3c69d9..32a64029d77b 100644
--- a/fs/hfsplus/unicode_test.c
+++ b/fs/hfsplus/unicode_test.c
@@ -10,6 +10,7 @@
 #include <linux/dcache.h>
 #include <linux/stringhash.h>
 #include "hfsplus_fs.h"
+#include "unicode.h"
 
 struct test_mock_string_env {
 	struct hfsplus_unistr str1;
-- 
2.43.0


^ permalink raw reply	[flat|nested] 4+ messages in thread

* [PATCH 2/3] hfsplus: canonicalize decomposed catalog names like fsck_hfs expects
  2026-09-19  0:16 [PATCH 0/3] hfsplus: fix "Illegal name" fsck failures from non-canonical decomposition Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 1/3] hfsplus: add Unicode combining-class and legacy decomposition data Viacheslav Dubeyko
@ 2026-09-19  0:16 ` Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 3/3] hfsplus: add KUnit coverage for canonical reordering and legacy fixups Viacheslav Dubeyko
  2 siblings, 0 replies; 4+ messages in thread
From: Viacheslav Dubeyko @ 2026-09-19  0:16 UTC (permalink / raw)
  To: glaubitz, frank.li
  Cc: linux-fsdevel, linux-kernel, vdubeyko, Viacheslav Dubeyko

hfsplus_decompose_table (Apple Technote #1150) decomposes each source
character entirely on its own. It never reorders the result, and it
predates a handful of later corrections to the decomposition standard.
Two concrete consequences, both of which macOS's own fsck_hfs treats as
"Illegal name" (CatalogCheck.c: CheckCatalogName(), FixDecomps()):

 - When combining marks contributed by two different source characters
   end up adjacent, this driver stores them in input order rather than
   ascending Unicode combining-class order.
 - A small, fixed set of characters either aren't decomposed at all,
   or are decomposed into a sequence macOS corrected back in
   Mac OS X 10.2 ("Jaguar").

This is reliably reproducible: xfstests generic/339 exercises dirhash
collisions by creating many files with randomized Unicode names, and
the resulting HFS+ volume fails _check_generic_filesystem afterward
with a long run of "Illegal name" reports from fsck.hfsplus.

Fix hfsplus_asc2uni() to store names the way current macOS does, using
the Unicode tables:

 - hfsplus_canonical_reorder() applies the Unicode Canonical Ordering
   Algorithm: each maximal run of nonzero-combining-class code units is
   stable-sorted into ascending class order.
 - decompose_unichar() falls back to hfsplus_legacy_decompose() for the
   characters missing from Apple Technote #1150's table.
 - hfsplus_fixup_legacy_sequences() substitutes any of fsck_hfs's known
   bad sequences with their corrected form.

hfsplus_decompose_str() ties these together and is now shared by
hfsplus_asc2uni(), hfsplus_hash_dentry() and hfsplus_compare_dentry(),
so hashing and comparison always agree with what actually gets stored -
otherwise two byte-for-byte different but canonically-equivalent
spellings of a name could hash differently or fail to compare equal
against the catalog entry a create() of either would produce.

Signed-off-by: Viacheslav Dubeyko <slava@dubeyko.com>
cc: John Paul Adrian Glaubitz <glaubitz@physik.fu-berlin.de>
cc: Yangtao Li <frank.li@vivo.com>
cc: linux-fsdevel@vger.kernel.org
---
 fs/hfsplus/tables.c  |  99 +++++++++++
 fs/hfsplus/unicode.c | 389 ++++++++++++++++++++++++++++---------------
 fs/hfsplus/unicode.h |   4 +
 3 files changed, 360 insertions(+), 132 deletions(-)

diff --git a/fs/hfsplus/tables.c b/fs/hfsplus/tables.c
index 3bbdc83debb5..2101201ab973 100644
--- a/fs/hfsplus/tables.c
+++ b/fs/hfsplus/tables.c
@@ -3641,3 +3641,102 @@ struct hfsplus_legacy_seq_fixup hfsplus_legacy_seq_fixups[] = {
 	{ 3, { 0x0fb2, 0x0f80, 0x0f71 }, 1, { 0x0f77 } },
 	{ 3, { 0x0fb3, 0x0f80, 0x0f71 }, 1, { 0x0f79 } },
 };
+
+/*
+ * Look up the Unicode canonical combining class of a BMP code point.
+ * Returns 0 ("Not_Reordered") for any code point not listed in the table,
+ * which includes every ordinary base character.
+ */
+u8 hfsplus_combining_class(u16 c)
+{
+	int lo = 0, hi = ARRAY_SIZE(hfsplus_ccc_table) - 1;
+
+	while (lo <= hi) {
+		int mid = (lo + hi) / 2;
+		const struct hfsplus_ccc_range *r = &hfsplus_ccc_table[mid];
+
+		if (c < r->first)
+			hi = mid - 1;
+		else if (c > r->last)
+			lo = mid + 1;
+		else
+			return r->combining_class;
+	}
+
+	return 0;
+}
+
+/*
+ * Look up the corrected canonical decomposition for a single BMP code
+ * point that Apple Technote #1150's own decomposition table (above)
+ * lacks. Returns NULL (and leaves *size alone) if @uc isn't one of these.
+ */
+const u16 *hfsplus_legacy_decompose(u16 uc, int *size)
+{
+	int lo = 0, hi = ARRAY_SIZE(hfsplus_legacy_decomp_table) - 1;
+
+	while (lo <= hi) {
+		int mid = (lo + hi) / 2;
+		const struct hfsplus_legacy_decomp *e =
+				&hfsplus_legacy_decomp_table[mid];
+
+		if (uc < e->uc)
+			hi = mid - 1;
+		else if (uc > e->uc)
+			lo = mid + 1;
+		else {
+			*size = e->len;
+			return e->repl;
+		}
+	}
+
+	return NULL;
+}
+
+/*
+ * Scan an already decomposed and canonically-reordered code unit buffer
+ * for any of the short legacy sequences above and replace them in place
+ * with their corrected form. @len is updated to the buffer's new length.
+ */
+void hfsplus_fixup_legacy_sequences(u16 *buf, int *len)
+{
+	int i = 0;
+
+	while (i < *len) {
+		unsigned int j;
+		bool matched = false;
+
+		for (j = 0; j < ARRAY_SIZE(hfsplus_legacy_seq_fixups); j++) {
+			struct hfsplus_legacy_seq_fixup *f =
+						&hfsplus_legacy_seq_fixups[j];
+			u16 *src, *dst;
+			size_t copy_len;
+			int k;
+
+			if (i + f->match_len > *len)
+				continue;
+			for (k = 0; k < f->match_len; k++)
+				if (buf[i + k] != f->match[k])
+					break;
+			if (k != f->match_len)
+				continue;
+
+			dst = &buf[i + f->repl_len];
+			src = &buf[i + f->match_len];
+			copy_len = (*len - i - f->match_len) * sizeof(*buf);
+			memmove(dst, src, copy_len);
+
+			dst = &buf[i];
+			src = f->repl;
+			copy_len = f->repl_len * sizeof(*buf);
+			memcpy(dst, src, copy_len);
+
+			*len += f->repl_len - f->match_len;
+			matched = true;
+			break;
+		}
+
+		if (!matched)
+			i++;
+	}
+}
diff --git a/fs/hfsplus/unicode.c b/fs/hfsplus/unicode.c
index 93d39481e477..59b8788a69a9 100644
--- a/fs/hfsplus/unicode.c
+++ b/fs/hfsplus/unicode.c
@@ -442,45 +442,264 @@ static u16 *decompose_unichar(wchar_t uc, int *size, u16 *hangul_buffer)
 	*size = hfsplus_try_decompose_hangul(uc, result);
 	if (*size == 0)
 		result = hfsplus_decompose_nonhangul(uc, size);
+	if (!result) {
+		/*
+		 * Not every character with a canonical decomposition is in
+		 * Apple Technote #1150's own table above; a small, fixed
+		 * set was only added to the decomposition standard (or had
+		 * its decomposition corrected) after that table was
+		 * generated. hfsplus_legacy_decompose() covers those.
+		 */
+		const u16 *legacy = hfsplus_legacy_decompose(uc, size);
+
+		if (legacy)
+			result = memcpy(hangul_buffer, legacy,
+					*size * sizeof(*legacy));
+	}
 	return result;
 }
 
-int hfsplus_asc2uni(struct super_block *sb,
-		    struct hfsplus_unistr *ustr, int max_unistr_len,
-		    const char *astr, int len, int name_type)
+/*
+ * Apply the Unicode Canonical Ordering Algorithm to a decomposed name held
+ * as plain host-order code units: within each maximal run of characters
+ * that have a nonzero combining class, stable-sort the run into ascending
+ * combining-class order. A character with combining class 0 always starts
+ * a new run and is never itself reordered.
+ *
+ * hfsplus_decompose_table decomposes each source character on its own; it
+ * says nothing about how the decompositions of two different source
+ * characters should be ordered relative to each other when both produce
+ * combining marks that end up adjacent. Without this pass, such a
+ * sequence can be stored in an order that macOS's own fsck_hfs
+ * (FixDecomps() in CatalogCheck.c) considers illegal, even though every
+ * individual character was decomposed correctly.
+ */
+static void hfsplus_canonical_reorder(u16 *ustr, int len)
 {
-	int size, dsize, decompose;
-	u16 *dstr, outlen = 0;
-	wchar_t c;
-	u16 dhangul[3];
+	int i;
+
+	for (i = 1; i < len; i++) {
+		u8 cls = hfsplus_combining_class(ustr[i]);
+		int j = i;
+
+		if (!cls)
+			continue;
+
+		while (j > 0) {
+			u8 prev_cls = hfsplus_combining_class(ustr[j - 1]);
+			u16 tmp;
+
+			if (!prev_cls || prev_cls <= cls)
+				break;
+
+			tmp = ustr[j];
+			ustr[j] = ustr[j - 1];
+			ustr[j - 1] = tmp;
+			j--;
+		}
+	}
+}
+
+#define HFSPLUS_HANGUL_MAX_JAMO		(3)       /* L + V + optional T */
+
+static_assert(HFSPLUS_HANGUL_MAX_JAMO >= HFSPLUS_LEGACY_DECOMP_MAX_LEN,
+		"hangul_buffer must fit the longest legacy decomposition too");
+
+/*
+ * Decompose and canonically reorder an entire Linux name, as plain
+ * host-order code units. hfsplus_asc2uni(), hfsplus_hash_dentry() and
+ * hfsplus_compare_dentry() all go through this so that storage, hashing
+ * and comparison always agree on what a given name canonicalizes to.
+ *
+ * @out must hold at least @max_len entries, which must not exceed
+ * HFSPLUS_MAX_STRLEN. Returns the number of code units written. If
+ * @consumed is non-NULL, it is set to the number of input bytes actually
+ * consumed, which is less than @len when @out fills up first.
+ */
+static int hfsplus_decompose_str(struct super_block *sb, const char *astr,
+				  int len, int max_len, int name_type,
+				  u16 *out, int *consumed)
+{
+	int decompose = !test_bit(HFSPLUS_SB_NODECOMPOSE, &HFSPLUS_SB(sb)->flags);
+	const char *start = astr;
+	int outlen = 0;
+
+	while (outlen < max_len && len > 0) {
+		u16 *dstr;
+		u16 dhangul[HFSPLUS_HANGUL_MAX_JAMO];
+		int dsize, size;
+		wchar_t c;
 
-	decompose = !test_bit(HFSPLUS_SB_NODECOMPOSE, &HFSPLUS_SB(sb)->flags);
-	while (outlen < max_unistr_len && len > 0) {
 		size = asc2unichar(sb, astr, len, &c, name_type);
 
-		if (decompose)
-			dstr = decompose_unichar(c, &dsize, dhangul);
-		else
-			dstr = NULL;
+		dstr = decompose ? decompose_unichar(c, &dsize, dhangul) : NULL;
 		if (dstr) {
-			if (outlen + dsize > max_unistr_len)
+			if (outlen + dsize > max_len)
 				break;
 			do {
-				ustr->unicode[outlen++] = cpu_to_be16(*dstr++);
+				out[outlen++] = *dstr++;
 			} while (--dsize > 0);
-		} else
-			ustr->unicode[outlen++] = cpu_to_be16(c);
+		} else {
+			out[outlen++] = c;
+		}
 
 		astr += size;
 		len -= size;
 	}
+
+	hfsplus_canonical_reorder(out, outlen);
+	hfsplus_fixup_legacy_sequences(out, &outlen);
+	if (consumed)
+		*consumed = astr - start;
+	return outlen;
+}
+
+int hfsplus_asc2uni(struct super_block *sb,
+		    struct hfsplus_unistr *ustr, int max_unistr_len,
+		    const char *astr, int len, int name_type)
+{
+	u16 buf[HFSPLUS_MAX_STRLEN];
+	int outlen, i, consumed;
+
+	if (max_unistr_len > HFSPLUS_MAX_STRLEN)
+		max_unistr_len = HFSPLUS_MAX_STRLEN;
+
+	outlen = hfsplus_decompose_str(sb, astr, len, max_unistr_len,
+				       name_type, buf, &consumed);
+	for (i = 0; i < outlen; i++)
+		ustr->unicode[i] = cpu_to_be16(buf[i]);
 	ustr->length = cpu_to_be16(outlen);
-	if (len > 0)
+
+	if (consumed < len)
 		return -ENAMETOOLONG;
 	return 0;
 }
 EXPORT_SYMBOL_IF_KUNIT(hfsplus_asc2uni);
 
+/*
+ * Maximum length of a single maximal run of nonzero-combining-class code
+ * units that hfsplus_decompose_iter_next() below will canonically
+ * reorder. Every code unit with combining class 0 starts a new run, so
+ * this only bounds how many *consecutive* combining marks between two
+ * base characters get sorted - real text, and even deliberately
+ * adversarial "Zalgo" text, essentially never approaches this. Keeping
+ * it small means hfsplus_hash_dentry() and hfsplus_compare_dentry() only
+ * ever need a tiny amount of lookahead state, rather than buffering an
+ * entire (up to 255-unit) name.
+ */
+#define HFSPLUS_CCC_RUN_MAX 32
+
+/*
+ * Iterator that produces the canonically-ordered, decomposed code units
+ * of a Linux name one at a time, without materializing the whole name.
+ * hfsplus_hash_dentry() and hfsplus_compare_dentry() use this so that
+ * hashing and comparison always agree with what hfsplus_asc2uni() would
+ * actually store, while still comparing lazily (stopping at the first
+ * difference) the way this code did before canonical reordering existed.
+ */
+struct hfsplus_decompose_iter {
+	struct super_block *sb;
+	const char *astr;
+	int len;
+	int name_type;
+	int decompose;
+
+	u16 run[HFSPLUS_CCC_RUN_MAX];
+	int run_len;
+	int run_pos;
+};
+
+static void hfsplus_decompose_iter_init(struct hfsplus_decompose_iter *it,
+					struct super_block *sb,
+					const char *astr, int len,
+					int name_type)
+{
+	it->sb = sb;
+	it->astr = astr;
+	it->len = len;
+	it->name_type = name_type;
+	it->decompose = !test_bit(HFSPLUS_SB_NODECOMPOSE, &HFSPLUS_SB(sb)->flags);
+	it->run_len = 0;
+	it->run_pos = 0;
+}
+
+/*
+ * Gather the next maximal run of nonzero-combining-class code units
+ * (starting with whatever character comes next, base or not) and
+ * canonically reorder just that run. A character is only included once
+ * we know the class of the first unit it produces, so decoding it is
+ * speculative until that's decided.
+ */
+static bool hfsplus_decompose_iter_refill(struct hfsplus_decompose_iter *it)
+{
+	it->run_pos = 0;
+	it->run_len = 0;
+
+	while (it->len > 0) {
+		u16 *dstr;
+		u16 dhangul[HFSPLUS_HANGUL_MAX_JAMO];
+		int dsize, size;
+		wchar_t c;
+
+		size = asc2unichar(it->sb, it->astr, it->len, &c,
+				   it->name_type);
+
+		dstr = it->decompose ?
+			decompose_unichar(c, &dsize, dhangul) : NULL;
+		if (!dstr) {
+			dhangul[0] = c;
+			dstr = dhangul;
+			dsize = 1;
+		}
+
+		if (it->run_len > 0 && !hfsplus_combining_class(dstr[0]))
+			break;
+
+		if (it->run_len + dsize > HFSPLUS_CCC_RUN_MAX)
+			break;
+
+		it->astr += size;
+		it->len -= size;
+
+		do {
+			it->run[it->run_len++] = *dstr++;
+		} while (--dsize > 0);
+	}
+
+	hfsplus_canonical_reorder(it->run, it->run_len);
+	hfsplus_fixup_legacy_sequences(it->run, &it->run_len);
+	return it->run_len > 0;
+}
+
+/* Returns the next code unit, or a negative value once @it is exhausted. */
+static int hfsplus_decompose_iter_next(struct hfsplus_decompose_iter *it)
+{
+	if (it->run_pos >= it->run_len && !hfsplus_decompose_iter_refill(it))
+		return -1;
+	return it->run[it->run_pos++];
+}
+
+/*
+ * Returns the next code unit that matters for comparison/hashing: folded
+ * if @casefold, with any character folding to 0 ("ignorable") skipped
+ * entirely. Returns a negative value once @it is exhausted.
+ */
+static int hfsplus_decompose_iter_next_folded(struct hfsplus_decompose_iter *it,
+					      int casefold)
+{
+	int c;
+
+	do {
+		c = hfsplus_decompose_iter_next(it);
+		if (c < 0)
+			return -1;
+		if (casefold)
+			c = case_fold(c);
+	} while (casefold && !c);
+
+	return c;
+}
+
 /*
  * Hash a string to an integer as appropriate for the HFS+ filesystem.
  * Composed unicode characters are decomposed and case-folding is performed
@@ -489,45 +708,17 @@ EXPORT_SYMBOL_IF_KUNIT(hfsplus_asc2uni);
 int hfsplus_hash_dentry(const struct dentry *dentry, struct qstr *str)
 {
 	struct super_block *sb = dentry->d_sb;
-	const char *astr;
-	const u16 *dstr;
-	int casefold, decompose, size, len;
+	int casefold = test_bit(HFSPLUS_SB_CASEFOLD, &HFSPLUS_SB(sb)->flags);
+	struct hfsplus_decompose_iter it;
 	unsigned long hash;
-	wchar_t c;
-	u16 c2;
-	u16 dhangul[3];
+	int c;
 
-	casefold = test_bit(HFSPLUS_SB_CASEFOLD, &HFSPLUS_SB(sb)->flags);
-	decompose = !test_bit(HFSPLUS_SB_NODECOMPOSE, &HFSPLUS_SB(sb)->flags);
-	hash = init_name_hash(dentry);
-	astr = str->name;
-	len = str->len;
-	while (len > 0) {
-		int dsize;
-		size = asc2unichar(sb, astr, len, &c, HFS_REGULAR_NAME);
-		astr += size;
-		len -= size;
+	hfsplus_decompose_iter_init(&it, sb, str->name, str->len,
+				    HFS_REGULAR_NAME);
 
-		if (decompose)
-			dstr = decompose_unichar(c, &dsize, dhangul);
-		else
-			dstr = NULL;
-		if (dstr) {
-			do {
-				c2 = *dstr++;
-				if (casefold)
-					c2 = case_fold(c2);
-				if (!casefold || c2)
-					hash = partial_name_hash(c2, hash);
-			} while (--dsize > 0);
-		} else {
-			c2 = c;
-			if (casefold)
-				c2 = case_fold(c2);
-			if (!casefold || c2)
-				hash = partial_name_hash(c2, hash);
-		}
-	}
+	hash = init_name_hash(dentry);
+	while ((c = hfsplus_decompose_iter_next_folded(&it, casefold)) >= 0)
+		hash = partial_name_hash(c, hash);
 	str->hash = end_name_hash(hash);
 
 	return 0;
@@ -543,87 +734,21 @@ int hfsplus_compare_dentry(const struct dentry *dentry,
 		unsigned int len, const char *str, const struct qstr *name)
 {
 	struct super_block *sb = dentry->d_sb;
-	int casefold, decompose, size;
-	int dsize1, dsize2, len1, len2;
-	const u16 *dstr1, *dstr2;
-	const char *astr1, *astr2;
-	u16 c1, c2;
-	wchar_t c;
-	u16 dhangul_1[3], dhangul_2[3];
-
-	casefold = test_bit(HFSPLUS_SB_CASEFOLD, &HFSPLUS_SB(sb)->flags);
-	decompose = !test_bit(HFSPLUS_SB_NODECOMPOSE, &HFSPLUS_SB(sb)->flags);
-	astr1 = str;
-	len1 = len;
-	astr2 = name->name;
-	len2 = name->len;
-	dsize1 = dsize2 = 0;
-	dstr1 = dstr2 = NULL;
-
-	while (len1 > 0 && len2 > 0) {
-		if (!dsize1) {
-			size = asc2unichar(sb, astr1, len1, &c,
-					   HFS_REGULAR_NAME);
-			astr1 += size;
-			len1 -= size;
-
-			if (decompose)
-				dstr1 = decompose_unichar(c, &dsize1,
-							  dhangul_1);
-			if (!decompose || !dstr1) {
-				c1 = c;
-				dstr1 = &c1;
-				dsize1 = 1;
-			}
-		}
+	int casefold = test_bit(HFSPLUS_SB_CASEFOLD, &HFSPLUS_SB(sb)->flags);
+	struct hfsplus_decompose_iter it1, it2;
 
-		if (!dsize2) {
-			size = asc2unichar(sb, astr2, len2, &c,
-					   HFS_REGULAR_NAME);
-			astr2 += size;
-			len2 -= size;
-
-			if (decompose)
-				dstr2 = decompose_unichar(c, &dsize2,
-							  dhangul_2);
-			if (!decompose || !dstr2) {
-				c2 = c;
-				dstr2 = &c2;
-				dsize2 = 1;
-			}
-		}
+	hfsplus_decompose_iter_init(&it1, sb, str, len, HFS_REGULAR_NAME);
+	hfsplus_decompose_iter_init(&it2, sb, name->name, name->len,
+				    HFS_REGULAR_NAME);
 
-		c1 = *dstr1;
-		c2 = *dstr2;
-		if (casefold) {
-			c1 = case_fold(c1);
-			if (!c1) {
-				dstr1++;
-				dsize1--;
-				continue;
-			}
-			c2 = case_fold(c2);
-			if (!c2) {
-				dstr2++;
-				dsize2--;
-				continue;
-			}
-		}
-		if (c1 < c2)
-			return -1;
-		else if (c1 > c2)
-			return 1;
+	while (1) {
+		int c1 = hfsplus_decompose_iter_next_folded(&it1, casefold);
+		int c2 = hfsplus_decompose_iter_next_folded(&it2, casefold);
 
-		dstr1++;
-		dsize1--;
-		dstr2++;
-		dsize2--;
+		if (c1 < 0 || c2 < 0)
+			return c1 == c2 ? 0 : (c1 < 0 ? -1 : 1);
+		if (c1 != c2)
+			return c1 < c2 ? -1 : 1;
 	}
-
-	if (len1 < len2)
-		return -1;
-	if (len1 > len2)
-		return 1;
-	return 0;
 }
 EXPORT_SYMBOL_IF_KUNIT(hfsplus_compare_dentry);
diff --git a/fs/hfsplus/unicode.h b/fs/hfsplus/unicode.h
index 3957ebccc232..31cc7691c916 100644
--- a/fs/hfsplus/unicode.h
+++ b/fs/hfsplus/unicode.h
@@ -56,4 +56,8 @@ extern struct hfsplus_ccc_range hfsplus_ccc_table[];
 extern struct hfsplus_legacy_decomp hfsplus_legacy_decomp_table[];
 extern struct hfsplus_legacy_seq_fixup hfsplus_legacy_seq_fixups[];
 
+u8 hfsplus_combining_class(u16 c);
+const u16 *hfsplus_legacy_decompose(u16 uc, int *size);
+void hfsplus_fixup_legacy_sequences(u16 *buf, int *len);
+
 #endif /* _LINUX_HFSPLUS_UNICODE_H */
-- 
2.43.0


^ permalink raw reply	[flat|nested] 4+ messages in thread

* [PATCH 3/3] hfsplus: add KUnit coverage for canonical reordering and legacy fixups
  2026-09-19  0:16 [PATCH 0/3] hfsplus: fix "Illegal name" fsck failures from non-canonical decomposition Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 1/3] hfsplus: add Unicode combining-class and legacy decomposition data Viacheslav Dubeyko
  2026-09-19  0:16 ` [PATCH 2/3] hfsplus: canonicalize decomposed catalog names like fsck_hfs expects Viacheslav Dubeyko
@ 2026-09-19  0:16 ` Viacheslav Dubeyko
  2 siblings, 0 replies; 4+ messages in thread
From: Viacheslav Dubeyko @ 2026-09-19  0:16 UTC (permalink / raw)
  To: glaubitz, frank.li
  Cc: linux-fsdevel, linux-kernel, vdubeyko, Viacheslav Dubeyko

Add regression tests for checking that decomposed catalog names
get canonically reordered and legacy-decomposition corrected
before storage, matching what macOS's own fsck_hfs requires and
previously flagged as "Illegal name" (reproducible via xfstests
generic/339).

Two small helpers, test_char2uni_utf8()/test_uni2char_utf8(), wrap the
real utf8_to_utf32()/utf32_to_utf8() codec: the existing
test_char2uni()/test_uni2char() stand-ins only handle one raw byte per
character and can't represent the multi-byte combining marks (U+0301,
U+0323, ...) these tests need.

New cases:

 - hfsplus_asc2uni_combining_reorder_test: two independently-typed
   combining marks (U+0301 class 230, U+0323 class 220) end up stored
   in ascending combining-class order regardless of typed order, are
   left alone when already canonical, and keep their relative order
   when their classes are equal (the sort must be stable).
 - hfsplus_unicode_combining_reorder_roundtrip_test: a name stored with
   marks reordered still reads back as the same, valid UTF-8.
 - hfsplus_asc2uni_legacy_decomp_test: U+01F8 decomposes to "N" +
   U+0300 even though Apple Technote #1150's table has no entry for it.
 - hfsplus_asc2uni_legacy_seq_fixup_test: a Greek letter + U+030D
   becomes that letter + U+0301, and Bengali BA + NUKTA collapses to
   the single letter RA WITH MIDDLE DIAGONAL.
 - hfsplus_hash_dentry_combining_reorder_test: two names differing only
   in typed mark order hash identically, since both canonicalize to the
   same stored form.
 - hfsplus_compare_dentry_combining_reorder_test and
   hfsplus_compare_dentry_legacy_decomp_test: likewise, both compare
   equal - a precomposed U+01F8 and its already-decomposed "N" + U+0300
   spelling must be found as the same catalog entry.

All 34 cases in the hfsplus_unicode KUnit suite (27 existing + 7 new)
pass under tools/testing/kunit/kunit.py.

Signed-off-by: Viacheslav Dubeyko <slava@dubeyko.com>
---
 fs/hfsplus/unicode_test.c | 308 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 308 insertions(+)

diff --git a/fs/hfsplus/unicode_test.c b/fs/hfsplus/unicode_test.c
index 32a64029d77b..a683c6d5fc22 100644
--- a/fs/hfsplus/unicode_test.c
+++ b/fs/hfsplus/unicode_test.c
@@ -948,6 +948,223 @@ static void hfsplus_asc2uni_decompose_test(struct kunit *test)
 	free_mock_sb(mock_sb);
 }
 
+/*
+ * Real UTF-8 <-> wchar_t conversion, mirroring what the "utf8" NLS table
+ * actually does (see char2uni()/uni2char() in fs/nls/nls_utf8.c). The
+ * test_char2uni()/test_uni2char() stand-ins above only handle one raw
+ * byte per character, which cannot represent the combining marks (e.g.
+ * U+0301, U+0323) the canonical-reordering tests below need.
+ */
+static int test_char2uni_utf8(const unsigned char *rawstring, int boundlen,
+			      wchar_t *uni)
+{
+	unicode_t u;
+	int n = utf8_to_utf32(rawstring, boundlen, &u);
+
+	if (n < 0 || u > MAX_WCHAR_T) {
+		*uni = 0x3f; /* ? */
+		return -EINVAL;
+	}
+	*uni = (wchar_t)u;
+	return n;
+}
+
+static int test_uni2char_utf8(wchar_t uni, unsigned char *out, int boundlen)
+{
+	int n = utf32_to_utf8(uni, out, boundlen);
+
+	if (n < 0) {
+		*out = '?';
+		return -EINVAL;
+	}
+	return n;
+}
+
+/*
+ * Test that hfsplus_asc2uni() brings combining marks contributed by
+ * different source characters into Unicode canonical (combining-class)
+ * order, instead of just storing them in whatever order they were typed.
+ *
+ * U+0301 (COMBINING ACUTE ACCENT) has combining class 230; U+0323
+ * (COMBINING DOT BELOW) has combining class 220. Since 220 < 230,
+ * canonical order requires the dot-below before the acute. A name
+ * created with them typed in the "wrong" order must still end up stored
+ * in canonical order - this is exactly what macOS's own fsck_hfs
+ * (FixDecomps() in CatalogCheck.c) requires and flags as "Illegal name"
+ * when it isn't true.
+ */
+static void hfsplus_asc2uni_combining_reorder_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct hfsplus_unistr ustr;
+	/* "e" + COMBINING ACUTE ACCENT (U+0301) + COMBINING DOT BELOW (U+0323) */
+	static const char wrong_order[] = "e\xcc\x81\xcc\xa3";
+	/* "e" + COMBINING DOT BELOW (U+0323) + COMBINING ACUTE ACCENT (U+0301) */
+	static const char canonical_order[] = "e\xcc\xa3\xcc\x81";
+	/* Two class-230 marks: GRAVE (U+0300) then ACUTE (U+0301) */
+	static const char same_class[] = "e\xcc\x80\xcc\x81";
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+
+	/* Typed out of canonical order: must be reordered on storage. */
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 wrong_order, strlen(wrong_order),
+				 HFS_REGULAR_NAME);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, 3, be16_to_cpu(ustr.length));
+	KUNIT_EXPECT_EQ(test, 'e', be16_to_cpu(ustr.unicode[0]));
+	KUNIT_EXPECT_EQ(test, 0x0323, be16_to_cpu(ustr.unicode[1]));
+	KUNIT_EXPECT_EQ(test, 0x0301, be16_to_cpu(ustr.unicode[2]));
+
+	/* Already in canonical order: must come out unchanged. */
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 canonical_order, strlen(canonical_order),
+				 HFS_REGULAR_NAME);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, 3, be16_to_cpu(ustr.length));
+	KUNIT_EXPECT_EQ(test, 'e', be16_to_cpu(ustr.unicode[0]));
+	KUNIT_EXPECT_EQ(test, 0x0323, be16_to_cpu(ustr.unicode[1]));
+	KUNIT_EXPECT_EQ(test, 0x0301, be16_to_cpu(ustr.unicode[2]));
+
+	/* Two marks of equal combining class must keep their relative
+	 * (input) order - the sort must be stable, not just "sorted".
+	 */
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 same_class, strlen(same_class),
+				 HFS_REGULAR_NAME);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, 3, be16_to_cpu(ustr.length));
+	KUNIT_EXPECT_EQ(test, 'e', be16_to_cpu(ustr.unicode[0]));
+	KUNIT_EXPECT_EQ(test, 0x0300, be16_to_cpu(ustr.unicode[1]));
+	KUNIT_EXPECT_EQ(test, 0x0301, be16_to_cpu(ustr.unicode[2]));
+
+	free_mock_sb(mock_sb);
+}
+
+/*
+ * Test that a name written with combining marks in non-canonical order
+ * still reads back as valid, correct UTF-8 once reordered - i.e. that
+ * hfsplus_asc2uni() and hfsplus_uni2asc_str() stay consistent with each
+ * other across the reordering.
+ *
+ * NODECOMPOSE is set so hfsplus_uni2asc_str() doesn't also recompose
+ * "e" + COMBINING DOT BELOW back into the precomposed U+1EB9 ("e"): that
+ * composition behavior is real (and already covered elsewhere), but it
+ * would obscure what this test is actually checking.
+ */
+static void hfsplus_unicode_combining_reorder_roundtrip_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct hfsplus_unistr ustr;
+	static const char wrong_order[] = "e\xcc\x81\xcc\xa3";
+	static const char expected[] = "e\xcc\xa3\xcc\x81"; /* canonical order */
+	char buf[32];
+	int len = sizeof(buf);
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	set_bit(HFSPLUS_SB_NODECOMPOSE, &mock_sb->sb_info.flags);
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+	mock_sb->nls.uni2char = test_uni2char_utf8;
+
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 wrong_order, strlen(wrong_order),
+				 HFS_REGULAR_NAME);
+	KUNIT_EXPECT_EQ(test, 0, result);
+
+	result = hfsplus_uni2asc_str(&mock_sb->sb, &ustr, buf, &len);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, (int)strlen(expected), len);
+	KUNIT_EXPECT_MEMEQ(test, expected, buf, len);
+
+	free_mock_sb(mock_sb);
+}
+
+/*
+ * Test that hfsplus_asc2uni() decomposes a character using the corrected
+ * (post-2002/"Jaguar") canonical decomposition even when Apple Technote
+ * #1150's own decomposition table doesn't have an entry for it.
+ *
+ * U+01F8 (LATIN CAPITAL LETTER N WITH GRAVE) is exactly one of the
+ * characters macOS's own fsck_hfs (FixDecomps() in CatalogCheck.c) has
+ * flagged "Illegal name" for since 2002 when found stored undecomposed.
+ */
+static void hfsplus_asc2uni_legacy_decomp_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct hfsplus_unistr ustr;
+	static const char input[] = "\xc7\xb8"; /* U+01F8 */
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 input, strlen(input), HFS_REGULAR_NAME);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, 2, be16_to_cpu(ustr.length));
+	KUNIT_EXPECT_EQ(test, 'N', be16_to_cpu(ustr.unicode[0]));
+	KUNIT_EXPECT_EQ(test, 0x0300, be16_to_cpu(ustr.unicode[1]));
+
+	free_mock_sb(mock_sb);
+}
+
+/*
+ * Test that hfsplus_asc2uni() corrects two more of fsck_hfs's known
+ * legacy decomposition sequences once combining marks from independently
+ * typed characters end up adjacent:
+ *
+ *  - GREEK SMALL LETTER ALPHA (U+03B1) + COMBINING VERTICAL LINE ABOVE
+ *    (U+030D) must become U+03B1 + COMBINING ACUTE ACCENT (U+0301).
+ *  - BENGALI LETTER BA (U+09AC) + BENGALI SIGN NUKTA (U+09BC) must become
+ *    the single character BENGALI LETTER RA WITH MIDDLE DIAGONAL (U+09B0).
+ */
+static void hfsplus_asc2uni_legacy_seq_fixup_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct hfsplus_unistr ustr;
+	static const char greek_input[] = "\xce\xb1\xcc\x8d"; /* U+03B1 U+030D */
+	static const char bengali_input[] = "\xe0\xa6\xac\xe0\xa6\xbc"; /* U+09AC U+09BC */
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 greek_input, strlen(greek_input),
+				 HFS_REGULAR_NAME);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, 2, be16_to_cpu(ustr.length));
+	KUNIT_EXPECT_EQ(test, 0x03b1, be16_to_cpu(ustr.unicode[0]));
+	KUNIT_EXPECT_EQ(test, 0x0301, be16_to_cpu(ustr.unicode[1]));
+
+	result = hfsplus_asc2uni(&mock_sb->sb, &ustr, HFSPLUS_MAX_STRLEN,
+				 bengali_input, strlen(bengali_input),
+				 HFS_REGULAR_NAME);
+
+	KUNIT_EXPECT_EQ(test, 0, result);
+	KUNIT_EXPECT_EQ(test, 1, be16_to_cpu(ustr.length));
+	KUNIT_EXPECT_EQ(test, 0x09b0, be16_to_cpu(ustr.unicode[0]));
+
+	free_mock_sb(mock_sb);
+}
+
 /* Mock dentry for testing hfsplus_hash_dentry */
 static struct dentry test_dentry;
 
@@ -1231,6 +1448,37 @@ static void hfsplus_hash_dentry_edge_cases_test(struct kunit *test)
 	free_mock_sb(mock_sb);
 }
 
+/*
+ * Test that hfsplus_hash_dentry() hashes two names identically when they
+ * differ only in the (non-canonical) typed order of the same combining
+ * marks - both must canonicalize to the same stored form, so they must
+ * hash the same or dcache lookups would spuriously miss.
+ */
+static void hfsplus_hash_dentry_combining_reorder_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct qstr str1, str2;
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	setup_mock_dentry(&mock_sb->sb);
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+
+	create_qstr(&str1, "e\xcc\x81\xcc\xa3"); /* acute, then dot-below */
+	result = hfsplus_hash_dentry(&test_dentry, &str1);
+	KUNIT_EXPECT_EQ(test, 0, result);
+
+	create_qstr(&str2, "e\xcc\xa3\xcc\x81"); /* dot-below, then acute */
+	result = hfsplus_hash_dentry(&test_dentry, &str2);
+	KUNIT_EXPECT_EQ(test, 0, result);
+
+	KUNIT_EXPECT_EQ(test, str1.hash, str2.hash);
+
+	free_mock_sb(mock_sb);
+}
+
 /* Test hfsplus_compare_dentry basic functionality */
 static void hfsplus_compare_dentry_basic_test(struct kunit *test)
 {
@@ -1553,6 +1801,59 @@ static void hfsplus_compare_dentry_combined_flags_test(struct kunit *test)
 	free_mock_sb(mock_sb);
 }
 
+/*
+ * Test that hfsplus_compare_dentry() treats two names as equal when they
+ * differ only in the (non-canonical) typed order of the same combining
+ * marks, since both refer to the same canonically-ordered catalog entry.
+ */
+static void hfsplus_compare_dentry_combining_reorder_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct qstr name;
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	setup_mock_dentry(&mock_sb->sb);
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+
+	create_qstr(&name, "e\xcc\xa3\xcc\x81"); /* dot-below, then acute */
+	result = hfsplus_compare_dentry(&test_dentry, 5, "e\xcc\x81\xcc\xa3",
+					&name); /* acute, then dot-below */
+	KUNIT_EXPECT_EQ(test, 0, result);
+
+	free_mock_sb(mock_sb);
+}
+
+/*
+ * Test that a precomposed character using one of fsck_hfs's known legacy
+ * decompositions compares equal to the already-decomposed form of the
+ * same character - i.e. that hfsplus_compare_dentry() applies the same
+ * legacy-decomposition correction as hfsplus_asc2uni() does on storage,
+ * so a lookup finds the entry regardless of which form was typed.
+ */
+static void hfsplus_compare_dentry_legacy_decomp_test(struct kunit *test)
+{
+	struct test_mock_sb *mock_sb;
+	struct qstr name;
+	int result;
+
+	mock_sb = setup_mock_sb();
+	KUNIT_ASSERT_NOT_NULL(test, mock_sb);
+
+	setup_mock_dentry(&mock_sb->sb);
+	mock_sb->nls.char2uni = test_char2uni_utf8;
+
+	/* "N" + COMBINING GRAVE ACCENT (U+0300), already decomposed */
+	create_qstr(&name, "N\xcc\x80");
+	/* U+01F8, precomposed */
+	result = hfsplus_compare_dentry(&test_dentry, 2, "\xc7\xb8", &name);
+	KUNIT_EXPECT_EQ(test, 0, result);
+
+	free_mock_sb(mock_sb);
+}
+
 static struct kunit_case hfsplus_unicode_test_cases[] = {
 	KUNIT_CASE(hfsplus_strcasecmp_test),
 	KUNIT_CASE(hfsplus_strcmp_test),
@@ -1568,12 +1869,17 @@ static struct kunit_case hfsplus_unicode_test_cases[] = {
 	KUNIT_CASE(hfsplus_asc2uni_buffer_limits_test),
 	KUNIT_CASE(hfsplus_asc2uni_edge_cases_test),
 	KUNIT_CASE(hfsplus_asc2uni_decompose_test),
+	KUNIT_CASE(hfsplus_asc2uni_combining_reorder_test),
+	KUNIT_CASE(hfsplus_unicode_combining_reorder_roundtrip_test),
+	KUNIT_CASE(hfsplus_asc2uni_legacy_decomp_test),
+	KUNIT_CASE(hfsplus_asc2uni_legacy_seq_fixup_test),
 	KUNIT_CASE(hfsplus_hash_dentry_basic_test),
 	KUNIT_CASE(hfsplus_hash_dentry_casefold_test),
 	KUNIT_CASE(hfsplus_hash_dentry_special_chars_test),
 	KUNIT_CASE(hfsplus_hash_dentry_decompose_test),
 	KUNIT_CASE(hfsplus_hash_dentry_consistency_test),
 	KUNIT_CASE(hfsplus_hash_dentry_edge_cases_test),
+	KUNIT_CASE(hfsplus_hash_dentry_combining_reorder_test),
 	KUNIT_CASE(hfsplus_compare_dentry_basic_test),
 	KUNIT_CASE(hfsplus_compare_dentry_casefold_test),
 	KUNIT_CASE(hfsplus_compare_dentry_special_chars_test),
@@ -1581,6 +1887,8 @@ static struct kunit_case hfsplus_unicode_test_cases[] = {
 	KUNIT_CASE(hfsplus_compare_dentry_decompose_test),
 	KUNIT_CASE(hfsplus_compare_dentry_edge_cases_test),
 	KUNIT_CASE(hfsplus_compare_dentry_combined_flags_test),
+	KUNIT_CASE(hfsplus_compare_dentry_combining_reorder_test),
+	KUNIT_CASE(hfsplus_compare_dentry_legacy_decomp_test),
 	{}
 };
 
-- 
2.43.0


^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-09-19  0:16 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-09-19  0:16 [PATCH 0/3] hfsplus: fix "Illegal name" fsck failures from non-canonical decomposition Viacheslav Dubeyko
2026-09-19  0:16 ` [PATCH 1/3] hfsplus: add Unicode combining-class and legacy decomposition data Viacheslav Dubeyko
2026-09-19  0:16 ` [PATCH 2/3] hfsplus: canonicalize decomposed catalog names like fsck_hfs expects Viacheslav Dubeyko
2026-09-19  0:16 ` [PATCH 3/3] hfsplus: add KUnit coverage for canonical reordering and legacy fixups Viacheslav Dubeyko

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®