View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.codec.digest;
19  
20  import static org.junit.jupiter.api.Assertions.assertArrayEquals;
21  import static org.junit.jupiter.api.Assertions.assertEquals;
22  import static org.junit.jupiter.api.Assertions.assertFalse;
23  import static org.junit.jupiter.api.Assertions.assertNotEquals;
24  import static org.junit.jupiter.api.Assertions.assertThrows;
25  
26  import java.io.ByteArrayInputStream;
27  import java.io.IOException;
28  import java.nio.charset.StandardCharsets;
29  import java.nio.file.Files;
30  import java.nio.file.Path;
31  import java.nio.file.Paths;
32  import java.nio.file.attribute.PosixFilePermissions;
33  import java.security.MessageDigest;
34  import java.util.ArrayList;
35  import java.util.Arrays;
36  import java.util.List;
37  import java.util.stream.Stream;
38  
39  import org.apache.commons.codec.DecoderException;
40  import org.apache.commons.codec.binary.Hex;
41  import org.apache.commons.codec.digest.GitIdentifiers.DirectoryEntry;
42  import org.junit.jupiter.api.Assumptions;
43  import org.junit.jupiter.api.Test;
44  import org.junit.jupiter.api.io.TempDir;
45  import org.junit.jupiter.params.ParameterizedTest;
46  import org.junit.jupiter.params.provider.Arguments;
47  import org.junit.jupiter.params.provider.MethodSource;
48  import org.junit.jupiter.params.provider.ValueSource;
49  
50  /**
51   * Tests {@link GitIdentifiers}.
52   */
53  class GitIdentifiersTest {
54  
55      private static final byte[] ZERO_ID = new byte[20];
56  
57      // Virtual tree:
58      //
59      // link      -> src           (symlink)
60      // link.txt  -> src/hello.txt (symlink)
61      // src/
62      //   hello.txt                (regular file)
63      //   run.sh                   (executable file)
64  
65      /** Content of {@code src/hello.txt}. */
66      private static final byte[] HELLO_CONTENT = "hello\n".getBytes(StandardCharsets.UTF_8);
67      /** SHA-1 blob id of {@link #HELLO_CONTENT}: {@code printf 'hello\n' | git hash-object --stdin} */
68      private static final byte[] HELLO_BLOB_ID_SHA1 = hex("ce013625030ba8dba906f756967f9e9ca394464a");
69      /** SHA-256 blob id of {@link #HELLO_CONTENT}. */
70      private static final byte[] HELLO_BLOB_ID_SHA256 = hex("2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4");
71  
72      /** Content of {@code src/run.sh}. */
73      private static final byte[] RUN_CONTENT = "#!/bin/sh\n".getBytes(StandardCharsets.UTF_8);
74      /** SHA-1 blob id of {@link #RUN_CONTENT}: {@code printf '#!/bin/sh\n' | git hash-object --stdin} */
75      private static final byte[] RUN_BLOB_ID_SHA1 = hex("1a2485251c33a70432394c93fb89330ef214bfc9");
76      /** SHA-256 blob id of {@link #RUN_CONTENT}. */
77      private static final byte[] RUN_BLOB_ID_SHA256 = hex("1249034e3cf9007362d695b09b1fbdb4c578903bf10b665749b94743f8177ce1");
78  
79      /** Target of symlink {@code link}. */
80      private static final String LINK_CONTENT = "src";
81      /** SHA-1 blob id of the symlink target {@link #LINK_CONTENT}: {@code printf 'src' | git hash-object --stdin} */
82      private static final byte[] LINK_BLOB_ID_SHA1 = hex("e8310385c56dc4bbe379f43400f3181f6a59f260");
83      /** SHA-256 blob id of the symlink target {@link #LINK_CONTENT}. */
84      private static final byte[] LINK_BLOB_ID_SHA256 = hex("e1bdca538422554ea204da85e0cec156b12b6808473083610ff95ea390843ab6");
85  
86      /** Target of symlink {@code link.txt}. */
87      private static final String LINK_TXT_CONTENT = "src/hello.txt";
88      /** SHA-1 blob id of the symlink target {@link #LINK_TXT_CONTENT}: {@code printf 'src/hello.txt' | git hash-object --stdin} */
89      private static final byte[] LINK_TXT_BLOB_ID_SHA1 = hex("132a953033e00dcff94f5cccb261f52cd1d71173");
90      /** SHA-256 blob id of the symlink target {@link #LINK_TXT_CONTENT}. */
91      private static final byte[] LINK_TXT_BLOB_ID_SHA256 = hex("2499925193a48a84a546a2f7cd3ce7789d4e073ef1e7276fe682bfbb2b636cef");
92  
93      // Tree ids can be recomputed in a git repository with:
94      //   git init /tmp/t && cd /tmp/t
95      // followed by writing the blob objects and calling git mktree.
96  
97      /**
98       * SHA-1 tree id of {@code src/} (hello.txt + run.sh):
99       * <pre>
100      * printf '100644 blob ce013625030ba8dba906f756967f9e9ca394464a\thello.txt\n
101      *         100755 blob 1a2485251c33a70432394c93fb89330ef214bfc9\trun.sh\n' | git mktree
102      * </pre>
103      */
104     private static final byte[] SRC_TREE_ID_SHA1 = hex("5575b4a0141a2287ec2836a620e5d6aa8fb203ba");
105     /**
106      * SHA-256 tree id of {@code src/}:
107      * <pre>
108      * printf '100644 blob 2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4\thello.txt\n
109      *         100755 blob 1249034e3cf9007362d695b09b1fbdb4c578903bf10b665749b94743f8177ce1\trun.sh\n' | git mktree
110      * </pre>
111      */
112     private static final byte[] SRC_TREE_ID_SHA256 = hex("5b4e74befcb98e3050c511d02353d00565b2172be0a2bc5de833f011ad27f694");
113 
114     /**
115      * SHA-1 tree id of the main directory (link + link.txt + src/):
116      * <pre>
117      * printf '120000 blob e8310385c56dc4bbe379f43400f3181f6a59f260\tlink\n
118      *         120000 blob 132a953033e00dcff94f5cccb261f52cd1d71173\tlink.txt\n
119      *         040000 tree 5575b4a0141a2287ec2836a620e5d6aa8fb203ba\tsrc\n' | git mktree
120      * </pre>
121      */
122     private static final byte[] MAIN_TREE_ID_SHA1 = hex("3217900fd0a6624cd6aa169c2a9f289f7f34432b");
123     /**
124      * SHA-256 tree id of the main directory:
125      * <pre>
126      * printf '120000 blob e1bdca538422554ea204da85e0cec156b12b6808473083610ff95ea390843ab6\tlink\n
127      *         120000 blob 2499925193a48a84a546a2f7cd3ce7789d4e073ef1e7276fe682bfbb2b636cef\tlink.txt\n
128      *         040000 tree 5b4e74befcb98e3050c511d02353d00565b2172be0a2bc5de833f011ad27f694\tsrc\n' | git mktree
129      * </pre>
130      */
131     private static final byte[] MAIN_TREE_ID_SHA256 = hex("58e9a59940e4d2ae7e374b63fedf3b7bba8cfdc60308f64abd066db137300bcd");
132 
133     static Stream<Arguments> blobIdProvider() {
134         return Stream.of(Arguments.of("DigestUtilsTest/hello.txt", "5f4a83288e67f1be2d6fcdad84165a86c6a970d7"),
135                 Arguments.of("DigestUtilsTest/greetings.txt", "6cf4f797455661e61d1ee6913fc29344f5897243"),
136                 Arguments.of("DigestUtilsTest/subdir/nested.txt", "07a392ddb4dbff06a373a7617939f30b2dcfe719"));
137     }
138 
139     /** Decodes a compile-time hex literal; throws {@link AssertionError} on malformed input. */
140     private static byte[] hex(final String hex) {
141         try {
142             return Hex.decodeHex(hex);
143         } catch (final DecoderException e) {
144             throw new AssertionError(e);
145         }
146     }
147 
148     private static Path resourcePath(final String resourceName) throws Exception {
149         return Paths.get(GitIdentifiersTest.class.getClassLoader().getResource(resourceName).toURI());
150     }
151 
152     static Stream<Arguments> virtualTreeProvider() {
153         return Stream.of(
154                 Arguments.of(MessageDigestAlgorithms.SHA_1, HELLO_BLOB_ID_SHA1, LINK_BLOB_ID_SHA1, LINK_TXT_BLOB_ID_SHA1, RUN_BLOB_ID_SHA1,
155                         SRC_TREE_ID_SHA1, MAIN_TREE_ID_SHA1),
156                 Arguments.of(MessageDigestAlgorithms.SHA_256, HELLO_BLOB_ID_SHA256, LINK_BLOB_ID_SHA256, LINK_TXT_BLOB_ID_SHA256, RUN_BLOB_ID_SHA256,
157                         SRC_TREE_ID_SHA256, MAIN_TREE_ID_SHA256));
158     }
159 
160     @ParameterizedTest
161     @MethodSource("blobIdProvider")
162     void testBlobIdByteArray(final String resourceName, final String expectedSha1Hex) throws Exception {
163         final byte[] data = Files.readAllBytes(resourcePath(resourceName));
164         assertArrayEquals(Hex.decodeHex(expectedSha1Hex), GitIdentifiers.blobId(DigestUtils.getSha1Digest(), data));
165     }
166 
167     @ParameterizedTest
168     @ValueSource(ints = { 0, 1, 8191, 8192, 8193, 20000 })
169     void testBlobIdInputStreamLengths(final int length) throws IOException {
170         final byte[] data = new byte[length];
171         Arrays.fill(data, (byte) 42);
172         assertArrayEquals(GitIdentifiers.blobId(DigestUtils.getSha1Digest(), data),
173                 GitIdentifiers.blobId(DigestUtils.getSha1Digest(), length, new ByteArrayInputStream(data)));
174         for (final long declaredSize : new long[] { -1, length - 1L, length + 1L }) {
175             assertThrows(IOException.class, () -> GitIdentifiers.blobId(DigestUtils.getSha1Digest(), declaredSize, new ByteArrayInputStream(data)));
176         }
177     }
178 
179     @ParameterizedTest
180     @MethodSource("blobIdProvider")
181     void testBlobIdInputStreamWithSize(final String resourceName, final String expectedSha1Hex) throws Exception {
182         final byte[] data = Files.readAllBytes(resourcePath(resourceName));
183         assertArrayEquals(Hex.decodeHex(expectedSha1Hex),
184                 GitIdentifiers.blobId(DigestUtils.getSha1Digest(), data.length, new ByteArrayInputStream(data)));
185     }
186 
187     @ParameterizedTest
188     @MethodSource("blobIdProvider")
189     void testBlobIdPath(final String resourceName, final String expectedSha1Hex) throws Exception {
190         assertArrayEquals(Hex.decodeHex(expectedSha1Hex), GitIdentifiers.blobId(DigestUtils.getSha1Digest(), resourcePath(resourceName)));
191     }
192 
193     @Test
194     void testBlobIdSymlink(@TempDir final Path tempDir) throws Exception {
195         final Path subDir = Files.createDirectory(tempDir.resolve("subdir"));
196         Files.write(subDir.resolve("file.txt"), "hello".getBytes(StandardCharsets.UTF_8));
197         try {
198             final Path linkToDir = Files.createSymbolicLink(tempDir.resolve("link-to-dir"), Paths.get("subdir"));
199             final Path linkToFile = Files.createSymbolicLink(tempDir.resolve("link-to-file"), Paths.get("subdir/file.txt"));
200             final MessageDigest sha1 = DigestUtils.getSha1Digest();
201             assertArrayEquals(Hex.decodeHex("8bbe8a53790056316b23b7c270f10ab6bf6bb1b4"), GitIdentifiers.blobId(sha1, linkToDir));
202             assertArrayEquals(Hex.decodeHex("dfe6ef8392ae13a11ff85419b4fd906d997b6cb7"), GitIdentifiers.blobId(sha1, linkToFile));
203         } catch (final UnsupportedOperationException e) {
204             Assumptions.abort("Symbolic links not supported on this filesystem");
205         }
206     }
207 
208     @Test
209     void testDirectoryEntryConstructor() {
210         assertThrows(NullPointerException.class, () -> new DirectoryEntry(null, GitIdentifiers.FileMode.REGULAR, ZERO_ID));
211         assertThrows(NullPointerException.class, () -> new DirectoryEntry("hello.txt", null, ZERO_ID));
212         assertThrows(NullPointerException.class, () -> new DirectoryEntry("hello.txt", GitIdentifiers.FileMode.REGULAR, null));
213         assertThrows(IllegalArgumentException.class, () -> new DirectoryEntry("/", GitIdentifiers.FileMode.REGULAR, ZERO_ID));
214     }
215 
216     /**
217      * Equality and hash code are based solely on the entry name.
218      */
219     @Test
220     void testDirectoryEntryEqualityBasedOnNameOnly() {
221         final byte[] otherId = new byte[20];
222         Arrays.fill(otherId, (byte) 0xff);
223         final DirectoryEntry regular = new DirectoryEntry("foo", GitIdentifiers.FileMode.REGULAR, ZERO_ID);
224         final DirectoryEntry executable = new DirectoryEntry("foo", GitIdentifiers.FileMode.EXECUTABLE, otherId);
225         // Same name, different type and object id -> equal
226         assertEquals(regular, executable);
227         assertEquals(regular.hashCode(), executable.hashCode());
228         // Different name -> not equal
229         assertNotEquals(regular, new DirectoryEntry("bar", GitIdentifiers.FileMode.REGULAR, ZERO_ID));
230         // Same reference -> equal
231         assertEquals(regular, regular);
232         // Not equal to null or unrelated type
233         assertFalse(regular.equals(null));
234         assertFalse(regular.equals("foo"));
235     }
236 
237     /**
238      * Entries should be sorted by Git sort rule.
239      *
240      * <p>
241      * Git compares the names of the entries, but adds a {@code /} at the end of directory entries.
242      * </p>
243      */
244     @Test
245     void testDirectoryEntrySortOrder() {
246         final DirectoryEntry alpha = new DirectoryEntry("alpha.txt", GitIdentifiers.FileMode.REGULAR, ZERO_ID);
247         final DirectoryEntry fooTxt = new DirectoryEntry("foo.txt", GitIdentifiers.FileMode.REGULAR, ZERO_ID);
248         final DirectoryEntry fooDir = new DirectoryEntry("foo", GitIdentifiers.FileMode.DIRECTORY, ZERO_ID);
249         final DirectoryEntry foobar = new DirectoryEntry("foobar", GitIdentifiers.FileMode.REGULAR, ZERO_ID);
250         final DirectoryEntry zeta = new DirectoryEntry("zeta.txt", GitIdentifiers.FileMode.REGULAR, ZERO_ID);
251         final List<DirectoryEntry> entries = new ArrayList<>(Arrays.asList(zeta, foobar, fooDir, alpha, fooTxt));
252         entries.sort(DirectoryEntry::compareTo);
253         assertEquals(Arrays.asList(alpha, fooTxt, fooDir, foobar, zeta), entries);
254     }
255 
256     @ParameterizedTest
257     @ValueSource(strings = {"..", "bad\0dir", "\uD800", "\uDC00"})
258     void testRejectsInvalidDirectoryNames(final String name) {
259         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
260         assertThrows(IllegalArgumentException.class, () -> builder.addDirectory("parent/" + name));
261         assertThrows(IllegalArgumentException.class, () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, name + "/file", HELLO_CONTENT));
262     }
263 
264     @ParameterizedTest
265     @ValueSource(strings = {"", ".", "..", "a\0b", "\uD800", "\uDC00", "a\uD800b", "\uD800\uD800", "\uDC00\uD800"})
266     void testRejectsInvalidEntryNames(final String name) {
267         assertThrows(IllegalArgumentException.class, () -> new DirectoryEntry(name, GitIdentifiers.FileMode.REGULAR, ZERO_ID));
268         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
269         assertThrows(IllegalArgumentException.class, () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, name, HELLO_CONTENT));
270         assertThrows(IllegalArgumentException.class,
271                 () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, name, HELLO_CONTENT.length, new ByteArrayInputStream(HELLO_CONTENT)));
272         assertThrows(IllegalArgumentException.class, () -> builder.addSymbolicLink(name, "target"));
273     }
274 
275     @ParameterizedTest
276     @ValueSource(strings = {"?", "a\nb", "\uD83D\uDE00"})
277     void testTreeIdAcceptsValidEntryNames(final String name) throws Exception {
278         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
279         builder.addFile(GitIdentifiers.FileMode.REGULAR, name, HELLO_CONTENT);
280         assertEquals(20, builder.get().length);
281         final GitIdentifiers.TreeIdBuilder directory = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
282         directory.addDirectory(name).addFile(GitIdentifiers.FileMode.REGULAR, "file", HELLO_CONTENT);
283         assertEquals(20, directory.get().length);
284     }
285 
286     @ParameterizedTest
287     @MethodSource("virtualTreeProvider")
288     void testTreeIdBuilder(final String algorithm, final byte[] helloId, final byte[] linkId, final byte[] linkTxtId, final byte[] runId,
289             final byte[] srcTreeId, final byte[] mainTreeId) throws Exception {
290         final MessageDigest md = DigestUtils.getDigest(algorithm);
291 
292         // Verify individual blob IDs against pre-computed constants.
293         assertArrayEquals(helloId, GitIdentifiers.blobId(md, HELLO_CONTENT));
294         assertArrayEquals(linkId, GitIdentifiers.blobId(md, LINK_CONTENT.getBytes(StandardCharsets.UTF_8)));
295         assertArrayEquals(linkTxtId, GitIdentifiers.blobId(md, LINK_TXT_CONTENT.getBytes(StandardCharsets.UTF_8)));
296         assertArrayEquals(runId, GitIdentifiers.blobId(md, RUN_CONTENT));
297 
298         // Entries are supplied out of order to verify that the builder sorts them correctly.
299         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(md);
300         builder.addSymbolicLink("link.txt", LINK_TXT_CONTENT);
301         builder.addFile(GitIdentifiers.FileMode.REGULAR, "src/hello.txt", HELLO_CONTENT);
302         builder.addSymbolicLink("link", LINK_CONTENT);
303         builder.addFile(GitIdentifiers.FileMode.EXECUTABLE, "src/run.sh", RUN_CONTENT);
304 
305         // Check trees
306         assertArrayEquals(mainTreeId, builder.get());
307         assertArrayEquals(srcTreeId, builder.addDirectory("src").get());
308     }
309 
310     @Test
311     void testTreeIdBuilderAddFileInputStream() throws Exception {
312         final MessageDigest md = DigestUtils.getSha1Digest();
313         final byte[] content = "Hello, World!\n".getBytes(StandardCharsets.UTF_8);
314 
315         final GitIdentifiers.TreeIdBuilder byteArrayBuilder = GitIdentifiers.treeIdBuilder(md);
316         byteArrayBuilder.addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content);
317         final byte[] expected = byteArrayBuilder.get();
318 
319         final GitIdentifiers.TreeIdBuilder sizedStreamBuilder = GitIdentifiers.treeIdBuilder(md);
320         sizedStreamBuilder.addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content.length, new ByteArrayInputStream(content));
321         assertArrayEquals(expected, sizedStreamBuilder.get());
322     }
323 
324     @Test
325     void testTreeIdBuilderInvalidPathSegments() {
326         final MessageDigest md = DigestUtils.getSha1Digest();
327         final byte[] data = {};
328         // Sole path component
329         assertThrows(IllegalArgumentException.class,
330                 () -> GitIdentifiers.treeIdBuilder(md).addFile(GitIdentifiers.FileMode.REGULAR, "..", data));
331         assertThrows(IllegalArgumentException.class,
332                 () -> GitIdentifiers.treeIdBuilder(md).addDirectory(".."));
333         // Embedded in a longer path
334         assertThrows(IllegalArgumentException.class,
335                 () -> GitIdentifiers.treeIdBuilder(md).addFile(GitIdentifiers.FileMode.REGULAR, "subdir/../file.txt", data));
336         assertThrows(IllegalArgumentException.class,
337                 () -> GitIdentifiers.treeIdBuilder(md).addDirectory("subdir/.."));
338     }
339 
340     @Test
341     void testTreeIdBuilderNestedFileEquivalentToDirectoryAndFile() throws Exception {
342         final MessageDigest md = DigestUtils.getSha1Digest();
343         final byte[] content = "hello\n".getBytes(StandardCharsets.UTF_8);
344 
345         final GitIdentifiers.TreeIdBuilder direct = GitIdentifiers.treeIdBuilder(md);
346         direct.addFile(GitIdentifiers.FileMode.REGULAR, "nested/file.txt", content);
347 
348         final GitIdentifiers.TreeIdBuilder indirect = GitIdentifiers.treeIdBuilder(md);
349         indirect.addDirectory("nested").addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content);
350 
351         assertArrayEquals(direct.get(), indirect.get());
352     }
353 
354     @ParameterizedTest
355     @ValueSource(strings = {"", "."})
356     void testTreeIdBuilderNoopPathSegments(final String segment) throws Exception {
357         final MessageDigest md = DigestUtils.getSha1Digest();
358         final byte[] content = "hello\n".getBytes(StandardCharsets.UTF_8);
359 
360         // Canonical form
361         final GitIdentifiers.TreeIdBuilder canonical = GitIdentifiers.treeIdBuilder(md);
362         canonical.addFile(GitIdentifiers.FileMode.REGULAR, "subdir/file.txt", content);
363         final byte[] expected = canonical.get();
364 
365         // Leading segment
366         final GitIdentifiers.TreeIdBuilder withLeading = GitIdentifiers.treeIdBuilder(md);
367         withLeading.addFile(GitIdentifiers.FileMode.REGULAR, segment + "/subdir/file.txt", content);
368         assertArrayEquals(expected, withLeading.get());
369 
370         // Intermediate segment
371         final GitIdentifiers.TreeIdBuilder withIntermediate = GitIdentifiers.treeIdBuilder(md);
372         withIntermediate.addFile(GitIdentifiers.FileMode.REGULAR, "subdir/" + segment + "/file.txt", content);
373         assertArrayEquals(expected, withIntermediate.get());
374 
375         // addDirectory with leading/trailing segments
376         final GitIdentifiers.TreeIdBuilder viaDirectory = GitIdentifiers.treeIdBuilder(md);
377         viaDirectory.addDirectory(segment + "/subdir/" + segment).addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content);
378         assertArrayEquals(expected, viaDirectory.get());
379     }
380 
381     @ParameterizedTest
382     @ValueSource(longs = { -1, 0, 5, 7 })
383     void testTreeIdBuilderRejectsWrongStreamSize(final long declaredSize) throws IOException {
384         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
385         builder.addFile(GitIdentifiers.FileMode.REGULAR, "hello.txt", HELLO_CONTENT);
386         final byte[] expected = builder.get();
387         assertThrows(IOException.class,
388                 () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, "hello.txt", declaredSize, new ByteArrayInputStream(HELLO_CONTENT)));
389         assertArrayEquals(expected, builder.get());
390     }
391 
392     @Test
393     void testTreeIdPath() throws Exception {
394         assertArrayEquals(Hex.decodeHex("e4b21f6d78ceba6eb7c211ac15e3337ec4614e8a"),
395                 GitIdentifiers.treeId(DigestUtils.getSha1Digest(), resourcePath("DigestUtilsTest")));
396     }
397 
398     @ParameterizedTest
399     @MethodSource("virtualTreeProvider")
400     void testTreeIdPathUnix(final String algorithm, final byte[] helloId, final byte[] linkId, final byte[] linkTxtId,
401             final byte[] runId, final byte[] srcTreeId, final byte[] mainTreeId, final @TempDir Path tempDir) throws Exception {
402         final MessageDigest md = DigestUtils.getDigest(algorithm);
403 
404         // Files
405         final Path link = tempDir.resolve("link");
406         final Path linkTxt = tempDir.resolve("link.txt");
407         final Path src = tempDir.resolve("src");
408         final Path hello = src.resolve("hello.txt");
409         final Path run = src.resolve("run.sh");
410 
411         // Create the same structure as the virtual tree.
412         try {
413             Files.createSymbolicLink(link, Paths.get(LINK_CONTENT));
414             Files.createSymbolicLink(linkTxt, Paths.get(LINK_TXT_CONTENT));
415         } catch (final UnsupportedOperationException e) {
416             Assumptions.abort("Symbolic links not supported on this filesystem");
417         }
418         Files.createDirectory(src);
419         Files.write(hello, HELLO_CONTENT);
420         Files.write(run, RUN_CONTENT);
421         Files.setPosixFilePermissions(run, PosixFilePermissions.fromString("rwxr-xr-x"));
422 
423         // Verify individual blob IDs against pre-computed constants.
424         assertArrayEquals(helloId, GitIdentifiers.blobId(md, hello));
425         assertArrayEquals(linkId, GitIdentifiers.blobId(md, link));
426         assertArrayEquals(linkTxtId, GitIdentifiers.blobId(md, linkTxt));
427         assertArrayEquals(runId, GitIdentifiers.blobId(md, run));
428 
429         // Check trees
430         assertArrayEquals(mainTreeId, GitIdentifiers.treeId(md, tempDir));
431         assertArrayEquals(srcTreeId, GitIdentifiers.treeId(md, src));
432     }
433 
434     @ParameterizedTest
435     @ValueSource(strings = {"x", "parent/x"})
436     void testTreeIdRejectsFileDirectoryConflicts(final String name) throws Exception {
437         final GitIdentifiers.TreeIdBuilder fileFirst = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
438         fileFirst.addFile(GitIdentifiers.FileMode.REGULAR, name, HELLO_CONTENT);
439         fileFirst.addDirectory(name);
440         assertThrows(IllegalStateException.class, fileFirst::get);
441         final GitIdentifiers.TreeIdBuilder directoryFirst = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
442         directoryFirst.addDirectory(name);
443         directoryFirst.addSymbolicLink(name, "target");
444         assertThrows(IllegalStateException.class, directoryFirst::get);
445     }
446 
447     @Test
448     void testTreeIdRejectsNulSerializationCollision() throws Exception {
449         final MessageDigest md = DigestUtils.getSha1Digest();
450         final byte[] a = "2161978".getBytes(StandardCharsets.UTF_8);
451         final byte[] b = "payload".getBytes(StandardCharsets.UTF_8);
452         final byte[] blobId = GitIdentifiers.blobId(md, a);
453         assertEquals("615d6b396b134e0c1a617b0b7050632d627d154f", Hex.encodeHexString(blobId));
454         final GitIdentifiers.TreeIdBuilder legitimate = GitIdentifiers.treeIdBuilder(md);
455         legitimate.addFile(GitIdentifiers.FileMode.REGULAR, "a", a);
456         legitimate.addFile(GitIdentifiers.FileMode.REGULAR, "b", b);
457         assertEquals("cb1e930df28b6dc3ab8933ff7a8d233f1c189460", Hex.encodeHexString(legitimate.get()));
458         // Without validation this single entry serializes identically to the legitimate two-entry tree.
459         final String forgedName = "a\0" + new String(blobId, StandardCharsets.UTF_8) + "100644 b";
460         final GitIdentifiers.TreeIdBuilder forged = GitIdentifiers.treeIdBuilder(md);
461         assertThrows(IllegalArgumentException.class, () -> forged.addFile(GitIdentifiers.FileMode.REGULAR, forgedName, b));
462     }
463 
464     @Test
465     void testTreeIdRejectsTrailingSlash() {
466         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest());
467         assertThrows(IllegalArgumentException.class, () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, "dir/", HELLO_CONTENT));
468     }
469 
470     /**
471      * 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
472      * 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
473      * the UTF-16 code units place the surrogate pair of U+1F600 first.
474      *
475      * <p>
476      * The expected identifier is the one {@code git write-tree} produces for a tree holding the same two entries.
477      * </p>
478      */
479     @Test
480     void testTreeIdSortsSupplementaryPlaneNamesLikeGit(@TempDir final Path tempDir) throws Exception {
481         final String fullWidthA = "\uFF21";
482         final String grinningFace = "\uD83D\uDE00";
483         final byte[] content = "x".getBytes(StandardCharsets.UTF_8);
484         final String expected = "9f9c1fc3580195f51d3e71b384ef1d57740e2151";
485         final MessageDigest md = DigestUtils.getSha1Digest();
486 
487         // Entries are added in the wrong order on purpose, so only the sort decides the result.
488         final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(md);
489         builder.addFile(GitIdentifiers.FileMode.REGULAR, grinningFace, content);
490         builder.addFile(GitIdentifiers.FileMode.REGULAR, fullWidthA, content);
491         assertEquals(expected, Hex.encodeHexString(builder.get()));
492 
493         try {
494             Files.write(tempDir.resolve(fullWidthA), content);
495             Files.write(tempDir.resolve(grinningFace), content);
496         } catch (final IOException e) {
497             Assumptions.abort("Filesystem cannot hold the test entry names: " + e);
498         }
499         assertEquals(expected, Hex.encodeHexString(GitIdentifiers.treeId(md, tempDir)));
500     }
501 }