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

garydgregory 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 87f6aa28 Reject ambiguous GitIdentifiers tree entry names
87f6aa28 is described below

commit 87f6aa2818836998528ff4b0d1ea35545a800ee5
Author: Gary Gregory <[email protected]>
AuthorDate: Fri Sep 18 06:09:35 2026 -0700

    Reject ambiguous GitIdentifiers tree entry names
    
    Reject empty, dot, NUL-containing, and malformed Unicode entry names.
    Reject file/directory name conflicts when computing tree identifiers.
    Add regression tests for serialization collisions and valid names,
    and document the fix in the release notes.
---
 src/changes/changes.xml                            |   1 +
 .../commons/codec/digest/GitIdentifiers.java       |  50 +++++---
 .../commons/codec/digest/GitIdentifiersTest.java   | 141 ++++++++++++++-------
 3 files changed, 129 insertions(+), 63 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 8e3a2410..e59c6bcf 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -59,6 +59,7 @@ The <action> type attribute can be add,update,fix,remove.
       <action type="fix" dev="ggregory" due-to="Gary Gregory">Grow Base58 
accumulation buffers geometrically within the configured input limits and 
validate actual accumulated length.</action>
       <action type="fix" dev="ggregory" due-to="Gary Gregory">Implement Base58 
encoded-length calculation and explicitly reject unsupported line 
chunking.</action>
       <action type="fix" dev="ggregory" due-to="Gary Gregory">Close the 
underlying BaseNCodecOutputStream output even when final conversion or flushing 
fails, preserving suppressed close exceptions.</action>
+      <action type="fix" dev="ggregory" due-to="Gary Gregory">Reject invalid 
GitIdentifiers tree entry names and file/directory name conflicts to prevent 
ambiguous tree serialization and colliding identifiers.</action>
       <!-- ADD -->
       <action type="add" dev="ggregory" due-to="Gary Gregory">Add and use 
PhoneticEngine.Builder and deprecate old constructors.</action>
       <action type="add" dev="ggregory" due-to="Gary Gregory">Add 
BeiderMorseEncoder.Builder and deprecate old constructor.</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 f9e42311..db5087dd 100644
--- a/src/main/java/org/apache/commons/codec/digest/GitIdentifiers.java
+++ b/src/main/java/org/apache/commons/codec/digest/GitIdentifiers.java
@@ -70,6 +70,20 @@ public class GitIdentifiers {
      */
     static class DirectoryEntry implements Comparable<DirectoryEntry> {
 
+        private static String requireValidName(final String name) {
+            Objects.requireNonNull(name, "name");
+            if (name.isEmpty() || ".".equals(name) || "..".equals(name)) {
+                throw new IllegalArgumentException("Entry name must not be 
empty, '.' or '..'");
+            }
+            if (name.indexOf('/') >= 0 || name.indexOf('\0') >= 0) {
+                throw new IllegalArgumentException("Entry name must not 
contain '/' or NUL");
+            }
+            if (!StandardCharsets.UTF_8.newEncoder().canEncode(name)) {
+                throw new IllegalArgumentException("Entry name must not 
contain unpaired surrogates");
+            }
+            return name;
+        }
+
         /**
          * The entry name (file or directory name, no path separator).
          */
@@ -95,15 +109,12 @@ public class GitIdentifiers {
         /**
          * Constructs a new entry.
          *
-         * @param name The name of the entry, not containing {@code '/'}.
+         * @param name The nonempty entry name, not {@code .} or {@code ..}, 
without {@code '/'}, NUL or unpaired surrogates.
          * @param type The type of the entry, not null.
          * @param rawObjectId The id of the entry, not null.
          */
         DirectoryEntry(final String name, final FileMode type, final byte[] 
rawObjectId) {
-            if (Objects.requireNonNull(name, "name").indexOf('/') >= 0) {
-                throw new IllegalArgumentException("Entry name must not 
contain '/': " + name);
-            }
-            this.name = name;
+            this.name = requireValidName(name);
             this.type = Objects.requireNonNull(type, "type");
             this.sortKey = (type == FileMode.DIRECTORY ? name + "/" : 
name).getBytes(StandardCharsets.UTF_8);
             this.rawObjectId = Objects.requireNonNull(rawObjectId, 
"rawObjectId");
@@ -225,13 +236,6 @@ public class GitIdentifiers {
             byte[] get() throws IOException;
         }
 
-        private static String requireNoParentTraversal(final String name) {
-            if ("..".equals(name)) {
-                throw new IllegalArgumentException("Path component not 
allowed: " + name);
-            }
-            return name;
-        }
-
         private final Map<String, TreeIdBuilder> dirEntries = new HashMap<>();
         private final Map<String, DirectoryEntry> fileEntries = new 
HashMap<>();
         private final MessageDigest messageDigest;
@@ -245,7 +249,7 @@ public class GitIdentifiers {
          *
          * @param name The relative path of the subdirectory in normalized 
form (may contain {@code '/'}).
          * @return The {@link TreeIdBuilder} for the subdirectory.
-         * @throws IllegalArgumentException If any path component is {@code 
".."}.
+         * @throws IllegalArgumentException If any path component is {@code 
".."}, contains NUL or contains unpaired surrogates.
          */
         public TreeIdBuilder addDirectory(final String name) {
             TreeIdBuilder current = this;
@@ -254,7 +258,7 @@ public class GitIdentifiers {
                 if (component.isEmpty() || ".".equals(component)) {
                     continue;
                 }
-                current = 
current.dirEntries.computeIfAbsent(requireNoParentTraversal(component), k -> 
new TreeIdBuilder(messageDigest));
+                current = 
current.dirEntries.computeIfAbsent(DirectoryEntry.requireValidName(component), 
k -> new TreeIdBuilder(messageDigest));
             }
             return current;
         }
@@ -262,7 +266,8 @@ public class GitIdentifiers {
         private void addFile(final FileMode mode, final String name, final 
BlobIdSupplier blobId) throws IOException {
             final int slash = name.lastIndexOf('/');
             if (slash < 0) {
-                fileEntries.put(name, new 
DirectoryEntry(requireNoParentTraversal(name), mode, blobId.get()));
+                DirectoryEntry.requireValidName(name);
+                fileEntries.put(name, new DirectoryEntry(name, mode, 
blobId.get()));
             } else {
                 addDirectory(name.substring(0, slash)).addFile(mode, 
name.substring(slash + 1), blobId);
             }
@@ -277,7 +282,8 @@ public class GitIdentifiers {
          * @param name The relative path of the entry in normalized form(may 
contain {@code '/'}).
          * @param data The file content.
          * @throws IOException If an I/O error occurs.
-         * @throws IllegalArgumentException If any path component is {@code 
".."}.
+         * @throws IllegalArgumentException If the entry name is empty or 
{@code "."}, or any path component is {@code ".."}, contains NUL or contains 
unpaired
+         *                                  surrogates.
          */
         public void addFile(final FileMode mode, final String name, final 
byte[] data) throws IOException {
             addFile(mode, name, () -> blobId(messageDigest, data));
@@ -295,7 +301,8 @@ public class GitIdentifiers {
          * @param dataSize The exact number of bytes in {@code data}.
          * @param data     The file content.
          * @throws IOException If the stream cannot be read.
-         * @throws IllegalArgumentException If any path component is {@code 
".."}.
+         * @throws IllegalArgumentException If the entry name is empty or 
{@code "."}, or any path component is {@code ".."}, contains NUL or contains 
unpaired
+         *                                  surrogates.
          */
         public void addFile(final FileMode mode, final String name, final long 
dataSize, final InputStream data) throws IOException {
             addFile(mode, name, () -> blobId(messageDigest, dataSize, data));
@@ -309,7 +316,8 @@ public class GitIdentifiers {
          * @param name The relative path of the entry in normalized form(may 
contain {@code '/'}).
          * @param target The target of the symbolic link.
          * @throws IOException If an I/O error occurs.
-         * @throws IllegalArgumentException If any path component is {@code 
".."}.
+         * @throws IllegalArgumentException If the entry name is empty or 
{@code "."}, or any path component is {@code ".."}, contains NUL or contains 
unpaired
+         *                                  surrogates.
          */
         public void addSymbolicLink(final String name, final String target) 
throws IOException {
             addFile(FileMode.SYMBOLIC_LINK, name, 
target.getBytes(StandardCharsets.UTF_8));
@@ -319,9 +327,15 @@ public class GitIdentifiers {
          * Computes the Git tree identifier for this directory and all its 
descendants.
          *
          * @return The raw tree identifier bytes.
+         * @throws IllegalStateException If a file and a directory have the 
same name in this directory or any descendant.
          */
         @Override
         public byte[] get() {
+            for (final String name : dirEntries.keySet()) {
+                if (fileEntries.containsKey(name)) {
+                    throw new IllegalStateException("File and directory have 
the same name: " + name);
+                }
+            }
             final Set<DirectoryEntry> entries = new 
TreeSet<>(fileEntries.values());
             dirEntries.forEach((k, v) -> entries.add(new DirectoryEntry(k, 
FileMode.DIRECTORY, v.get())));
             final ByteArrayOutputStream baos = new ByteArrayOutputStream();
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 bae2a761..0fbfeab1 100644
--- a/src/test/java/org/apache/commons/codec/digest/GitIdentifiersTest.java
+++ b/src/test/java/org/apache/commons/codec/digest/GitIdentifiersTest.java
@@ -222,51 +222,6 @@ 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.
      *
@@ -284,6 +239,36 @@ class GitIdentifiersTest {
         assertEquals(Arrays.asList(alpha, fooTxt, fooDir, foobar, zeta), 
entries);
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"..", "bad\0dir", "\uD800", "\uDC00"})
+    void testRejectsInvalidDirectoryNames(final String name) {
+        final GitIdentifiers.TreeIdBuilder builder = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        assertThrows(IllegalArgumentException.class, () -> 
builder.addDirectory("parent/" + name));
+        assertThrows(IllegalArgumentException.class, () -> 
builder.addFile(GitIdentifiers.FileMode.REGULAR, name + "/file", 
HELLO_CONTENT));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"", ".", "..", "a\0b", "\uD800", "\uDC00", 
"a\uD800b", "\uD800\uD800", "\uDC00\uD800"})
+    void testRejectsInvalidEntryNames(final String name) {
+        assertThrows(IllegalArgumentException.class, () -> new 
DirectoryEntry(name, GitIdentifiers.FileMode.REGULAR, ZERO_ID));
+        final GitIdentifiers.TreeIdBuilder builder = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        assertThrows(IllegalArgumentException.class, () -> 
builder.addFile(GitIdentifiers.FileMode.REGULAR, name, HELLO_CONTENT));
+        assertThrows(IllegalArgumentException.class,
+                () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, name, 
HELLO_CONTENT.length, new ByteArrayInputStream(HELLO_CONTENT)));
+        assertThrows(IllegalArgumentException.class, () -> 
builder.addSymbolicLink(name, "target"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"?", "a\nb", "\uD83D\uDE00"})
+    void testTreeIdAcceptsValidEntryNames(final String name) throws Exception {
+        final GitIdentifiers.TreeIdBuilder builder = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        builder.addFile(GitIdentifiers.FileMode.REGULAR, name, HELLO_CONTENT);
+        assertEquals(20, builder.get().length);
+        final GitIdentifiers.TreeIdBuilder directory = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        directory.addDirectory(name).addFile(GitIdentifiers.FileMode.REGULAR, 
"file", HELLO_CONTENT);
+        assertEquals(20, directory.get().length);
+    }
+
     @ParameterizedTest
     @MethodSource("virtualTreeProvider")
     void testTreeIdBuilder(final String algorithm, final byte[] helloId, final 
byte[] linkId, final byte[] linkTxtId, final byte[] runId,
@@ -420,4 +405,70 @@ class GitIdentifiersTest {
         assertArrayEquals(mainTreeId, GitIdentifiers.treeId(md, tempDir));
         assertArrayEquals(srcTreeId, GitIdentifiers.treeId(md, src));
     }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"x", "parent/x"})
+    void testTreeIdRejectsFileDirectoryConflicts(final String name) throws 
Exception {
+        final GitIdentifiers.TreeIdBuilder fileFirst = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        fileFirst.addFile(GitIdentifiers.FileMode.REGULAR, name, 
HELLO_CONTENT);
+        fileFirst.addDirectory(name);
+        assertThrows(IllegalStateException.class, fileFirst::get);
+        final GitIdentifiers.TreeIdBuilder directoryFirst = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        directoryFirst.addDirectory(name);
+        directoryFirst.addSymbolicLink(name, "target");
+        assertThrows(IllegalStateException.class, directoryFirst::get);
+    }
+
+    @Test
+    void testTreeIdRejectsNulSerializationCollision() throws Exception {
+        final MessageDigest md = DigestUtils.getSha1Digest();
+        final byte[] a = "2161978".getBytes(StandardCharsets.UTF_8);
+        final byte[] b = "payload".getBytes(StandardCharsets.UTF_8);
+        final byte[] blobId = GitIdentifiers.blobId(md, a);
+        assertEquals("615d6b396b134e0c1a617b0b7050632d627d154f", 
Hex.encodeHexString(blobId));
+        final GitIdentifiers.TreeIdBuilder legitimate = 
GitIdentifiers.treeIdBuilder(md);
+        legitimate.addFile(GitIdentifiers.FileMode.REGULAR, "a", a);
+        legitimate.addFile(GitIdentifiers.FileMode.REGULAR, "b", b);
+        assertEquals("cb1e930df28b6dc3ab8933ff7a8d233f1c189460", 
Hex.encodeHexString(legitimate.get()));
+        // Without validation this single entry serializes identically to the 
legitimate two-entry tree.
+        final String forgedName = "a\0" + new String(blobId, 
StandardCharsets.UTF_8) + "100644 b";
+        final GitIdentifiers.TreeIdBuilder forged = 
GitIdentifiers.treeIdBuilder(md);
+        assertThrows(IllegalArgumentException.class, () -> 
forged.addFile(GitIdentifiers.FileMode.REGULAR, forgedName, b));
+    }
+
+    @Test
+    void testTreeIdRejectsTrailingSlash() {
+        final GitIdentifiers.TreeIdBuilder builder = 
GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
+        assertThrows(IllegalArgumentException.class, () -> 
builder.addFile(GitIdentifiers.FileMode.REGULAR, "dir/", HELLO_CONTENT));
+    }
+
+    /**
+     * 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)));
+    }
 }

Reply via email to