| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * https://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package org.apache.commons.codec.digest; |
| |
| import static org.junit.jupiter.api.Assertions.assertArrayEquals; |
| import static org.junit.jupiter.api.Assertions.assertEquals; |
| import static org.junit.jupiter.api.Assertions.assertFalse; |
| 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; |
| import java.nio.file.Paths; |
| import java.nio.file.attribute.PosixFilePermissions; |
| import java.security.MessageDigest; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.stream.Stream; |
| |
| import org.apache.commons.codec.DecoderException; |
| import org.apache.commons.codec.binary.Hex; |
| import org.apache.commons.codec.digest.GitIdentifiers.DirectoryEntry; |
| import org.junit.jupiter.api.Assumptions; |
| import org.junit.jupiter.api.Test; |
| import org.junit.jupiter.api.io.TempDir; |
| import org.junit.jupiter.params.ParameterizedTest; |
| import org.junit.jupiter.params.provider.Arguments; |
| import org.junit.jupiter.params.provider.MethodSource; |
| import org.junit.jupiter.params.provider.ValueSource; |
| |
| /** |
| * Tests {@link GitIdentifiers}. |
| */ |
| class GitIdentifiersTest { |
| |
| private static final byte[] ZERO_ID = new byte[20]; |
| |
| // Virtual tree: |
| // |
| // link -> src (symlink) |
| // link.txt -> src/hello.txt (symlink) |
| // src/ |
| // hello.txt (regular file) |
| // run.sh (executable file) |
| |
| /** Content of {@code src/hello.txt}. */ |
| private static final byte[] HELLO_CONTENT = "hello\n".getBytes(StandardCharsets.UTF_8); |
| /** SHA-1 blob id of {@link #HELLO_CONTENT}: {@code printf 'hello\n' | git hash-object --stdin} */ |
| private static final byte[] HELLO_BLOB_ID_SHA1 = hex("ce013625030ba8dba906f756967f9e9ca394464a"); |
| /** SHA-256 blob id of {@link #HELLO_CONTENT}. */ |
| private static final byte[] HELLO_BLOB_ID_SHA256 = hex("2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4"); |
| |
| /** Content of {@code src/run.sh}. */ |
| private static final byte[] RUN_CONTENT = "#!/bin/sh\n".getBytes(StandardCharsets.UTF_8); |
| /** SHA-1 blob id of {@link #RUN_CONTENT}: {@code printf '#!/bin/sh\n' | git hash-object --stdin} */ |
| private static final byte[] RUN_BLOB_ID_SHA1 = hex("1a2485251c33a70432394c93fb89330ef214bfc9"); |
| /** SHA-256 blob id of {@link #RUN_CONTENT}. */ |
| private static final byte[] RUN_BLOB_ID_SHA256 = hex("1249034e3cf9007362d695b09b1fbdb4c578903bf10b665749b94743f8177ce1"); |
| |
| /** Target of symlink {@code link}. */ |
| private static final String LINK_CONTENT = "src"; |
| /** SHA-1 blob id of the symlink target {@link #LINK_CONTENT}: {@code printf 'src' | git hash-object --stdin} */ |
| private static final byte[] LINK_BLOB_ID_SHA1 = hex("e8310385c56dc4bbe379f43400f3181f6a59f260"); |
| /** SHA-256 blob id of the symlink target {@link #LINK_CONTENT}. */ |
| private static final byte[] LINK_BLOB_ID_SHA256 = hex("e1bdca538422554ea204da85e0cec156b12b6808473083610ff95ea390843ab6"); |
| |
| /** Target of symlink {@code link.txt}. */ |
| private static final String LINK_TXT_CONTENT = "src/hello.txt"; |
| /** SHA-1 blob id of the symlink target {@link #LINK_TXT_CONTENT}: {@code printf 'src/hello.txt' | git hash-object --stdin} */ |
| private static final byte[] LINK_TXT_BLOB_ID_SHA1 = hex("132a953033e00dcff94f5cccb261f52cd1d71173"); |
| /** SHA-256 blob id of the symlink target {@link #LINK_TXT_CONTENT}. */ |
| private static final byte[] LINK_TXT_BLOB_ID_SHA256 = hex("2499925193a48a84a546a2f7cd3ce7789d4e073ef1e7276fe682bfbb2b636cef"); |
| |
| // Tree ids can be recomputed in a git repository with: |
| // git init /tmp/t && cd /tmp/t |
| // followed by writing the blob objects and calling git mktree. |
| |
| /** |
| * SHA-1 tree id of {@code src/} (hello.txt + run.sh): |
| * <pre> |
| * printf '100644 blob ce013625030ba8dba906f756967f9e9ca394464a\thello.txt\n |
| * 100755 blob 1a2485251c33a70432394c93fb89330ef214bfc9\trun.sh\n' | git mktree |
| * </pre> |
| */ |
| private static final byte[] SRC_TREE_ID_SHA1 = hex("5575b4a0141a2287ec2836a620e5d6aa8fb203ba"); |
| /** |
| * SHA-256 tree id of {@code src/}: |
| * <pre> |
| * printf '100644 blob 2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4\thello.txt\n |
| * 100755 blob 1249034e3cf9007362d695b09b1fbdb4c578903bf10b665749b94743f8177ce1\trun.sh\n' | git mktree |
| * </pre> |
| */ |
| private static final byte[] SRC_TREE_ID_SHA256 = hex("5b4e74befcb98e3050c511d02353d00565b2172be0a2bc5de833f011ad27f694"); |
| |
| /** |
| * SHA-1 tree id of the main directory (link + link.txt + src/): |
| * <pre> |
| * printf '120000 blob e8310385c56dc4bbe379f43400f3181f6a59f260\tlink\n |
| * 120000 blob 132a953033e00dcff94f5cccb261f52cd1d71173\tlink.txt\n |
| * 040000 tree 5575b4a0141a2287ec2836a620e5d6aa8fb203ba\tsrc\n' | git mktree |
| * </pre> |
| */ |
| private static final byte[] MAIN_TREE_ID_SHA1 = hex("3217900fd0a6624cd6aa169c2a9f289f7f34432b"); |
| /** |
| * SHA-256 tree id of the main directory: |
| * <pre> |
| * printf '120000 blob e1bdca538422554ea204da85e0cec156b12b6808473083610ff95ea390843ab6\tlink\n |
| * 120000 blob 2499925193a48a84a546a2f7cd3ce7789d4e073ef1e7276fe682bfbb2b636cef\tlink.txt\n |
| * 040000 tree 5b4e74befcb98e3050c511d02353d00565b2172be0a2bc5de833f011ad27f694\tsrc\n' | git mktree |
| * </pre> |
| */ |
| private static final byte[] MAIN_TREE_ID_SHA256 = hex("58e9a59940e4d2ae7e374b63fedf3b7bba8cfdc60308f64abd066db137300bcd"); |
| |
| static Stream<Arguments> blobIdProvider() { |
| return Stream.of(Arguments.of("DigestUtilsTest/hello.txt", "5f4a83288e67f1be2d6fcdad84165a86c6a970d7"), |
| Arguments.of("DigestUtilsTest/greetings.txt", "6cf4f797455661e61d1ee6913fc29344f5897243"), |
| Arguments.of("DigestUtilsTest/subdir/nested.txt", "07a392ddb4dbff06a373a7617939f30b2dcfe719")); |
| } |
| |
| /** Decodes a compile-time hex literal; throws {@link AssertionError} on malformed input. */ |
| private static byte[] hex(final String hex) { |
| try { |
| return Hex.decodeHex(hex); |
| } catch (final DecoderException e) { |
| throw new AssertionError(e); |
| } |
| } |
| |
| private static Path resourcePath(final String resourceName) throws Exception { |
| return Paths.get(GitIdentifiersTest.class.getClassLoader().getResource(resourceName).toURI()); |
| } |
| |
| static Stream<Arguments> virtualTreeProvider() { |
| return Stream.of( |
| Arguments.of(MessageDigestAlgorithms.SHA_1, HELLO_BLOB_ID_SHA1, LINK_BLOB_ID_SHA1, LINK_TXT_BLOB_ID_SHA1, RUN_BLOB_ID_SHA1, |
| SRC_TREE_ID_SHA1, MAIN_TREE_ID_SHA1), |
| Arguments.of(MessageDigestAlgorithms.SHA_256, HELLO_BLOB_ID_SHA256, LINK_BLOB_ID_SHA256, LINK_TXT_BLOB_ID_SHA256, RUN_BLOB_ID_SHA256, |
| SRC_TREE_ID_SHA256, MAIN_TREE_ID_SHA256)); |
| } |
| |
| @ParameterizedTest |
| @MethodSource("blobIdProvider") |
| void testBlobIdByteArray(final String resourceName, final String expectedSha1Hex) throws Exception { |
| final byte[] data = Files.readAllBytes(resourcePath(resourceName)); |
| assertArrayEquals(Hex.decodeHex(expectedSha1Hex), GitIdentifiers.blobId(DigestUtils.getSha1Digest(), data)); |
| } |
| |
| @ParameterizedTest |
| @ValueSource(ints = { 0, 1, 8191, 8192, 8193, 20000 }) |
| void testBlobIdInputStreamLengths(final int length) throws IOException { |
| final byte[] data = new byte[length]; |
| Arrays.fill(data, (byte) 42); |
| assertArrayEquals(GitIdentifiers.blobId(DigestUtils.getSha1Digest(), data), |
| GitIdentifiers.blobId(DigestUtils.getSha1Digest(), length, new ByteArrayInputStream(data))); |
| for (final long declaredSize : new long[] { -1, length - 1L, length + 1L }) { |
| assertThrows(IOException.class, () -> GitIdentifiers.blobId(DigestUtils.getSha1Digest(), declaredSize, new ByteArrayInputStream(data))); |
| } |
| } |
| |
| @ParameterizedTest |
| @MethodSource("blobIdProvider") |
| void testBlobIdInputStreamWithSize(final String resourceName, final String expectedSha1Hex) throws Exception { |
| final byte[] data = Files.readAllBytes(resourcePath(resourceName)); |
| assertArrayEquals(Hex.decodeHex(expectedSha1Hex), |
| GitIdentifiers.blobId(DigestUtils.getSha1Digest(), data.length, new ByteArrayInputStream(data))); |
| } |
| |
| @ParameterizedTest |
| @MethodSource("blobIdProvider") |
| void testBlobIdPath(final String resourceName, final String expectedSha1Hex) throws Exception { |
| assertArrayEquals(Hex.decodeHex(expectedSha1Hex), GitIdentifiers.blobId(DigestUtils.getSha1Digest(), resourcePath(resourceName))); |
| } |
| |
| @Test |
| void testBlobIdSymlink(@TempDir final Path tempDir) throws Exception { |
| final Path subDir = Files.createDirectory(tempDir.resolve("subdir")); |
| Files.write(subDir.resolve("file.txt"), "hello".getBytes(StandardCharsets.UTF_8)); |
| try { |
| final Path linkToDir = Files.createSymbolicLink(tempDir.resolve("link-to-dir"), Paths.get("subdir")); |
| final Path linkToFile = Files.createSymbolicLink(tempDir.resolve("link-to-file"), Paths.get("subdir/file.txt")); |
| final MessageDigest sha1 = DigestUtils.getSha1Digest(); |
| assertArrayEquals(Hex.decodeHex("8bbe8a53790056316b23b7c270f10ab6bf6bb1b4"), GitIdentifiers.blobId(sha1, linkToDir)); |
| assertArrayEquals(Hex.decodeHex("dfe6ef8392ae13a11ff85419b4fd906d997b6cb7"), GitIdentifiers.blobId(sha1, linkToFile)); |
| } catch (final UnsupportedOperationException e) { |
| Assumptions.abort("Symbolic links not supported on this filesystem"); |
| } |
| } |
| |
| @Test |
| void testDirectoryEntryConstructor() { |
| assertThrows(NullPointerException.class, () -> new DirectoryEntry(null, GitIdentifiers.FileMode.REGULAR, ZERO_ID)); |
| assertThrows(NullPointerException.class, () -> new DirectoryEntry("hello.txt", null, ZERO_ID)); |
| assertThrows(NullPointerException.class, () -> new DirectoryEntry("hello.txt", GitIdentifiers.FileMode.REGULAR, null)); |
| assertThrows(IllegalArgumentException.class, () -> new DirectoryEntry("/", GitIdentifiers.FileMode.REGULAR, ZERO_ID)); |
| } |
| |
| /** |
| * Equality and hash code are based solely on the entry name. |
| */ |
| @Test |
| void testDirectoryEntryEqualityBasedOnNameOnly() { |
| final byte[] otherId = new byte[20]; |
| Arrays.fill(otherId, (byte) 0xff); |
| final DirectoryEntry regular = new DirectoryEntry("foo", GitIdentifiers.FileMode.REGULAR, ZERO_ID); |
| final DirectoryEntry executable = new DirectoryEntry("foo", GitIdentifiers.FileMode.EXECUTABLE, otherId); |
| // Same name, different type and object id -> equal |
| assertEquals(regular, executable); |
| assertEquals(regular.hashCode(), executable.hashCode()); |
| // Different name -> not equal |
| assertNotEquals(regular, new DirectoryEntry("bar", GitIdentifiers.FileMode.REGULAR, ZERO_ID)); |
| // Same reference -> equal |
| assertEquals(regular, regular); |
| // Not equal to null or unrelated type |
| assertFalse(regular.equals(null)); |
| assertFalse(regular.equals("foo")); |
| } |
| |
| /** |
| * Entries should be sorted by Git sort rule. |
| * |
| * <p> |
| * Git compares the names of the entries, but adds a {@code /} at the end of directory entries. |
| * </p> |
| */ |
| @Test |
| void testDirectoryEntrySortOrder() { |
| final DirectoryEntry alpha = new DirectoryEntry("alpha.txt", GitIdentifiers.FileMode.REGULAR, ZERO_ID); |
| final DirectoryEntry fooTxt = new DirectoryEntry("foo.txt", GitIdentifiers.FileMode.REGULAR, ZERO_ID); |
| final DirectoryEntry fooDir = new DirectoryEntry("foo", GitIdentifiers.FileMode.DIRECTORY, ZERO_ID); |
| final DirectoryEntry foobar = new DirectoryEntry("foobar", GitIdentifiers.FileMode.REGULAR, ZERO_ID); |
| final DirectoryEntry zeta = new DirectoryEntry("zeta.txt", GitIdentifiers.FileMode.REGULAR, ZERO_ID); |
| final List<DirectoryEntry> entries = new ArrayList<>(Arrays.asList(zeta, foobar, fooDir, alpha, fooTxt)); |
| entries.sort(DirectoryEntry::compareTo); |
| 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, |
| final byte[] srcTreeId, final byte[] mainTreeId) throws Exception { |
| final MessageDigest md = DigestUtils.getDigest(algorithm); |
| |
| // Verify individual blob IDs against pre-computed constants. |
| assertArrayEquals(helloId, GitIdentifiers.blobId(md, HELLO_CONTENT)); |
| assertArrayEquals(linkId, GitIdentifiers.blobId(md, LINK_CONTENT.getBytes(StandardCharsets.UTF_8))); |
| assertArrayEquals(linkTxtId, GitIdentifiers.blobId(md, LINK_TXT_CONTENT.getBytes(StandardCharsets.UTF_8))); |
| assertArrayEquals(runId, GitIdentifiers.blobId(md, RUN_CONTENT)); |
| |
| // Entries are supplied out of order to verify that the builder sorts them correctly. |
| final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(md); |
| builder.addSymbolicLink("link.txt", LINK_TXT_CONTENT); |
| builder.addFile(GitIdentifiers.FileMode.REGULAR, "src/hello.txt", HELLO_CONTENT); |
| builder.addSymbolicLink("link", LINK_CONTENT); |
| builder.addFile(GitIdentifiers.FileMode.EXECUTABLE, "src/run.sh", RUN_CONTENT); |
| |
| // Check trees |
| assertArrayEquals(mainTreeId, builder.get()); |
| assertArrayEquals(srcTreeId, builder.addDirectory("src").get()); |
| } |
| |
| @Test |
| void testTreeIdBuilderAddFileInputStream() throws Exception { |
| final MessageDigest md = DigestUtils.getSha1Digest(); |
| final byte[] content = "Hello, World!\n".getBytes(StandardCharsets.UTF_8); |
| |
| final GitIdentifiers.TreeIdBuilder byteArrayBuilder = GitIdentifiers.treeIdBuilder(md); |
| byteArrayBuilder.addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content); |
| final byte[] expected = byteArrayBuilder.get(); |
| |
| final GitIdentifiers.TreeIdBuilder sizedStreamBuilder = GitIdentifiers.treeIdBuilder(md); |
| sizedStreamBuilder.addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content.length, new ByteArrayInputStream(content)); |
| assertArrayEquals(expected, sizedStreamBuilder.get()); |
| } |
| |
| @Test |
| void testTreeIdBuilderInvalidPathSegments() { |
| final MessageDigest md = DigestUtils.getSha1Digest(); |
| final byte[] data = {}; |
| // Sole path component |
| assertThrows(IllegalArgumentException.class, |
| () -> GitIdentifiers.treeIdBuilder(md).addFile(GitIdentifiers.FileMode.REGULAR, "..", data)); |
| assertThrows(IllegalArgumentException.class, |
| () -> GitIdentifiers.treeIdBuilder(md).addDirectory("..")); |
| // Embedded in a longer path |
| assertThrows(IllegalArgumentException.class, |
| () -> GitIdentifiers.treeIdBuilder(md).addFile(GitIdentifiers.FileMode.REGULAR, "subdir/../file.txt", data)); |
| assertThrows(IllegalArgumentException.class, |
| () -> GitIdentifiers.treeIdBuilder(md).addDirectory("subdir/..")); |
| } |
| |
| @Test |
| void testTreeIdBuilderNestedFileEquivalentToDirectoryAndFile() throws Exception { |
| final MessageDigest md = DigestUtils.getSha1Digest(); |
| final byte[] content = "hello\n".getBytes(StandardCharsets.UTF_8); |
| |
| final GitIdentifiers.TreeIdBuilder direct = GitIdentifiers.treeIdBuilder(md); |
| direct.addFile(GitIdentifiers.FileMode.REGULAR, "nested/file.txt", content); |
| |
| final GitIdentifiers.TreeIdBuilder indirect = GitIdentifiers.treeIdBuilder(md); |
| indirect.addDirectory("nested").addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content); |
| |
| assertArrayEquals(direct.get(), indirect.get()); |
| } |
| |
| @ParameterizedTest |
| @ValueSource(strings = {"", "."}) |
| void testTreeIdBuilderNoopPathSegments(final String segment) throws Exception { |
| final MessageDigest md = DigestUtils.getSha1Digest(); |
| final byte[] content = "hello\n".getBytes(StandardCharsets.UTF_8); |
| |
| // Canonical form |
| final GitIdentifiers.TreeIdBuilder canonical = GitIdentifiers.treeIdBuilder(md); |
| canonical.addFile(GitIdentifiers.FileMode.REGULAR, "subdir/file.txt", content); |
| final byte[] expected = canonical.get(); |
| |
| // Leading segment |
| final GitIdentifiers.TreeIdBuilder withLeading = GitIdentifiers.treeIdBuilder(md); |
| withLeading.addFile(GitIdentifiers.FileMode.REGULAR, segment + "/subdir/file.txt", content); |
| assertArrayEquals(expected, withLeading.get()); |
| |
| // Intermediate segment |
| final GitIdentifiers.TreeIdBuilder withIntermediate = GitIdentifiers.treeIdBuilder(md); |
| withIntermediate.addFile(GitIdentifiers.FileMode.REGULAR, "subdir/" + segment + "/file.txt", content); |
| assertArrayEquals(expected, withIntermediate.get()); |
| |
| // addDirectory with leading/trailing segments |
| final GitIdentifiers.TreeIdBuilder viaDirectory = GitIdentifiers.treeIdBuilder(md); |
| viaDirectory.addDirectory(segment + "/subdir/" + segment).addFile(GitIdentifiers.FileMode.REGULAR, "file.txt", content); |
| assertArrayEquals(expected, viaDirectory.get()); |
| } |
| |
| @ParameterizedTest |
| @ValueSource(longs = { -1, 0, 5, 7 }) |
| void testTreeIdBuilderRejectsWrongStreamSize(final long declaredSize) throws IOException { |
| final GitIdentifiers.TreeIdBuilder builder = GitIdentifiers.treeIdBuilder(DigestUtils.getSha1Digest()); |
| builder.addFile(GitIdentifiers.FileMode.REGULAR, "hello.txt", HELLO_CONTENT); |
| final byte[] expected = builder.get(); |
| assertThrows(IOException.class, |
| () -> builder.addFile(GitIdentifiers.FileMode.REGULAR, "hello.txt", declaredSize, new ByteArrayInputStream(HELLO_CONTENT))); |
| assertArrayEquals(expected, builder.get()); |
| } |
| |
| @Test |
| void testTreeIdPath() throws Exception { |
| assertArrayEquals(Hex.decodeHex("e4b21f6d78ceba6eb7c211ac15e3337ec4614e8a"), |
| GitIdentifiers.treeId(DigestUtils.getSha1Digest(), resourcePath("DigestUtilsTest"))); |
| } |
| |
| @ParameterizedTest |
| @MethodSource("virtualTreeProvider") |
| void testTreeIdPathUnix(final String algorithm, final byte[] helloId, final byte[] linkId, final byte[] linkTxtId, |
| final byte[] runId, final byte[] srcTreeId, final byte[] mainTreeId, final @TempDir Path tempDir) throws Exception { |
| final MessageDigest md = DigestUtils.getDigest(algorithm); |
| |
| // Files |
| final Path link = tempDir.resolve("link"); |
| final Path linkTxt = tempDir.resolve("link.txt"); |
| final Path src = tempDir.resolve("src"); |
| final Path hello = src.resolve("hello.txt"); |
| final Path run = src.resolve("run.sh"); |
| |
| // Create the same structure as the virtual tree. |
| try { |
| Files.createSymbolicLink(link, Paths.get(LINK_CONTENT)); |
| Files.createSymbolicLink(linkTxt, Paths.get(LINK_TXT_CONTENT)); |
| } catch (final UnsupportedOperationException e) { |
| Assumptions.abort("Symbolic links not supported on this filesystem"); |
| } |
| Files.createDirectory(src); |
| Files.write(hello, HELLO_CONTENT); |
| Files.write(run, RUN_CONTENT); |
| Files.setPosixFilePermissions(run, PosixFilePermissions.fromString("rwxr-xr-x")); |
| |
| // Verify individual blob IDs against pre-computed constants. |
| assertArrayEquals(helloId, GitIdentifiers.blobId(md, hello)); |
| assertArrayEquals(linkId, GitIdentifiers.blobId(md, link)); |
| assertArrayEquals(linkTxtId, GitIdentifiers.blobId(md, linkTxt)); |
| assertArrayEquals(runId, GitIdentifiers.blobId(md, run)); |
| |
| // Check trees |
| 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))); |
| } |
| } |