This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-codec.git


The following commit(s) were added to refs/heads/master by this push:
     new f49b9638 GitIdentifiers: order tree entries by UTF-8 bytes as Git does 
(#443)
f49b9638 is described below

commit f49b96388c2d97d093d8669cf74f57a5a92e7dff
Author: Jeff Lenamon <[email protected]>
AuthorDate: Mon Sep 14 13:55:50 2026 -0400

    GitIdentifiers: order tree entries by UTF-8 bytes as Git does (#443)
    
    * GitIdentifiers: order tree entries by UTF-8 bytes as Git does
    
    DirectoryEntry.compareTo used String.compareTo, which orders UTF-16
    code units; Git orders the raw UTF-8 bytes, and the two disagree for
    names outside the Basic Multilingual Plane, so treeId returned an id
    git write-tree does not. The sort key is now the name's UTF-8 bytes,
    compared unsigned. A test pins the id git write-tree produces for a
    two-entry tree and fails on the old comparator.
    
    * Keep tree entries whose names share UTF-8 bytes
    
    String.getBytes replaces a lone surrogate with '?', so a name holding one
    had the same sort key as a name holding '?', and the TreeSet in
    TreeIdBuilder.get kept only one of them. compareTo now falls back to the
    String order when the bytes tie, which keeps both, as the String
    comparator did. The class and test descriptions of the ordering are
    reworded.
    
    * Document the UTF-8 encoding of names and link targets
    
    Git and SWHID treat file names and symbolic link targets as bytes with
    no defined encoding, and this class encodes both as UTF-8, so its
    identifiers match theirs only when the originals were UTF-8. The
    paragraph is the one proposed in the review, with link targets added.
---
 src/changes/changes.xml                            |  1 +
 .../commons/codec/digest/GitIdentifiers.java       | 28 +++++++++----
 .../commons/codec/digest/GitIdentifiersTest.java   | 46 ++++++++++++++++++++++
 3 files changed, 68 insertions(+), 7 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index c881d90e..ae10848b 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -45,6 +45,7 @@ The <action> type attribute can be add,update,fix,remove.
   <body>
     <release version="1.23.0" date="YYYY-MM-DD" description="This is a feature 
and maintenance release. Java 8 or later is required.">
       <!-- FIX -->
+      <action type="fix" dev="ggregory" due-to="Jeff Lenamon">GitIdentifiers 
orders tree entries by unsigned UTF-8 bytes, as Git does, instead of UTF-16 
code units.</action>
       <action type="fix" dev="ggregory" due-to="Yu Bao, Gary Gregory">Optimize 
PhoneticEngine.encode(String, LanguageSet) for speed.</action>
       <action type="fix" dev="ggregory" due-to="Yu Bao, Gary 
Gregory">RFC1522Codec.decodeText(String) now throws a DecoderException instead 
of a StringIndexOutOfBoundsException when a separator is missing.</action>
       <action type="fix" dev="ggregory" due-to="Yu Bao, Gary Gregory">Optimize 
Base58.convertFromBase58(byte[], Context) for speed and temporary object 
allocation.</action>
diff --git a/src/main/java/org/apache/commons/codec/digest/GitIdentifiers.java 
b/src/main/java/org/apache/commons/codec/digest/GitIdentifiers.java
index 0f7a3015..f9e42311 100644
--- a/src/main/java/org/apache/commons/codec/digest/GitIdentifiers.java
+++ b/src/main/java/org/apache/commons/codec/digest/GitIdentifiers.java
@@ -39,6 +39,10 @@ import java.util.function.Supplier;
  * <p>When the hash algorithm is SHA-1, the identifiers produced by this class 
are identical to those used by Git.
  * Other hash algorithms produce generalized identifiers as described by the 
SWHID specification.</p>
  *
+ * <p>Git and SWHID treat file names and symbolic link targets as opaque byte 
sequences with no defined encoding. The
+ * identifiers produced here coincide with Git's or SWHID's own identifiers 
only if the original names and targets were
+ * UTF-8 encoded.</p>
+ *
  * <p>This class is immutable and thread-safe. However, the {@link 
MessageDigest} instances passed to it generally won't be.</p>
  *
  * @see <a href="https://git-scm.com/book/en/v2/Git-Internals-Git-Objects";>Git 
Internals – Git Objects</a>
@@ -57,8 +61,9 @@ public class GitIdentifiers {
      *   <li>the raw object id of the referenced blob or sub-tree.</li>
      * </ul>
      *
-     * <p>Entries are ordered by {@link #compareTo} using Git's tree-sort 
rule: directory names are compared as if they ended with {@code '/'}, so that 
{@code foo/}
-     * sorts after {@code foobar}.</p>
+     * <p>Entries are ordered by {@link #compareTo} using Git's tree-sort 
rule: names are compared as unsigned UTF-8 bytes, and directory names are 
compared as if
+     * they ended with {@code '/'}, so that {@code foo/} sorts after {@code 
foobar}. Comparing the UTF-8 bytes rather than the Java {@link String} matches 
Git's
+     * order for names outside the Basic Multilingual Plane, whose UTF-16 code 
units do not sort in code point order.</p>
      *
      * @see <a 
href="https://git-scm.com/book/en/v2/Git-Internals-Git-Objects";>Git Internals – 
Git Objects</a>
      * @see <a 
href="https://www.swhid.org/swhid-specification/v1.2/5.Core_identifiers/#53-directories";>SWHID
 Directory Identifier</a>
@@ -76,11 +81,11 @@ public class GitIdentifiers {
         private final byte[] rawObjectId;
 
         /**
-         * The key used for ordering entries within a tree object.
+         * The key used for ordering entries within a tree object, as the 
UTF-8 bytes Git itself compares.
          *
-         * <p>>Git appends {@code '/'} to directory names before comparing.</p>
+         * <p>Git appends {@code '/'} to directory names before comparing.</p>
          */
-        private final String sortKey;
+        private final byte[] sortKey;
 
         /**
          * The Git object type, which determines the Unix file-mode prefix.
@@ -100,13 +105,22 @@ public class GitIdentifiers {
             }
             this.name = name;
             this.type = Objects.requireNonNull(type, "type");
-            this.sortKey = type == FileMode.DIRECTORY ? name + "/" : name;
+            this.sortKey = (type == FileMode.DIRECTORY ? name + "/" : 
name).getBytes(StandardCharsets.UTF_8);
             this.rawObjectId = Objects.requireNonNull(rawObjectId, 
"rawObjectId");
         }
 
         @Override
         public int compareTo(final DirectoryEntry o) {
-            return sortKey.compareTo(o.sortKey);
+            final byte[] a = sortKey;
+            final byte[] b = o.sortKey;
+            final int shared = Math.min(a.length, b.length);
+            for (int i = 0; i < shared; i++) {
+                final int diff = (a[i] & 0xff) - (b[i] & 0xff);
+                if (diff != 0) {
+                    return diff;
+                }
+            }
+            return a.length != b.length ? a.length - b.length : 
name.compareTo(o.name);
         }
 
         @Override
diff --git 
a/src/test/java/org/apache/commons/codec/digest/GitIdentifiersTest.java 
b/src/test/java/org/apache/commons/codec/digest/GitIdentifiersTest.java
index e455173d..bae2a761 100644
--- a/src/test/java/org/apache/commons/codec/digest/GitIdentifiersTest.java
+++ b/src/test/java/org/apache/commons/codec/digest/GitIdentifiersTest.java
@@ -24,6 +24,7 @@ import static 
org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -221,6 +222,51 @@ class GitIdentifiersTest {
         assertFalse(regular.equals("foo"));
     }
 
+    /**
+     * Tree entry names are ordered by their UTF-8 bytes, which is not the 
order {@link String#compareTo(String)} gives when a supplementary character 
meets a
+     * Basic Multilingual Plane character from U+E000 up: U+FF21 encodes to 
{@code EF BC A1} and U+1F600 to {@code F0 9F 98 80}, so Git sorts U+FF21 first, 
while
+     * the UTF-16 code units place the surrogate pair of U+1F600 first.
+     *
+     * <p>The expected identifier is the one {@code git write-tree} produces 
for a tree holding the same two entries.</p>
+     */
+    @Test
+    void testTreeIdSortsSupplementaryPlaneNamesLikeGit(@TempDir final Path 
tempDir) throws Exception {
+        final String fullWidthA = "\uFF21";
+        final String grinningFace = "\uD83D\uDE00";
+        final byte[] content = "x".getBytes(StandardCharsets.UTF_8);
+        final String expected = "9f9c1fc3580195f51d3e71b384ef1d57740e2151";
+        final MessageDigest md = DigestUtils.getSha1Digest();
+
+        // Entries are added in the wrong order on purpose, so only the sort 
decides the result.
+        final GitIdentifiers.TreeIdBuilder builder = 
GitIdentifiers.treeIdBuilder(md);
+        builder.addFile(GitIdentifiers.FileMode.REGULAR, grinningFace, 
content);
+        builder.addFile(GitIdentifiers.FileMode.REGULAR, fullWidthA, content);
+        assertEquals(expected, Hex.encodeHexString(builder.get()));
+
+        try {
+            Files.write(tempDir.resolve(fullWidthA), content);
+            Files.write(tempDir.resolve(grinningFace), content);
+        } catch (final IOException e) {
+            Assumptions.abort("Filesystem cannot hold the test entry names: " 
+ e);
+        }
+        assertEquals(expected, Hex.encodeHexString(GitIdentifiers.treeId(md, 
tempDir)));
+    }
+
+    /**
+     * A lone surrogate encodes to {@code ?} in UTF-8, the same byte as a 
question mark, so the two names share a sort key; both entries must stay in the 
tree.
+     */
+    @Test
+    void testTreeIdKeepsNamesWithTheSameUtf8Bytes() throws Exception {
+        final byte[] content = "x".getBytes(StandardCharsets.UTF_8);
+        final MessageDigest md = DigestUtils.getSha1Digest();
+        final GitIdentifiers.TreeIdBuilder one = 
GitIdentifiers.treeIdBuilder(md);
+        one.addFile(GitIdentifiers.FileMode.REGULAR, "?", content);
+        final GitIdentifiers.TreeIdBuilder both = 
GitIdentifiers.treeIdBuilder(md);
+        both.addFile(GitIdentifiers.FileMode.REGULAR, "?", content);
+        both.addFile(GitIdentifiers.FileMode.REGULAR, "\uD800", content);
+        assertNotEquals(Hex.encodeHexString(one.get()), 
Hex.encodeHexString(both.get()));
+    }
+
     /**
      * Entries should be sorted by Git sort rule.
      *

Reply via email to