| /* | |
| * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved. | |
| * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. | |
| * | |
| * This code is free software; you can redistribute it and/or modify it | |
| * under the terms of the GNU General Public License version 2 only, as | |
| * published by the Free Software Foundation. | |
| * | |
| * This code is distributed in the hope that it will be useful, but WITHOUT | |
| * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | |
| * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License | |
| * version 2 for more details (a copy is included in the LICENSE file that | |
| * accompanied this code). | |
| * | |
| * You should have received a copy of the GNU General Public License version | |
| * 2 along with this work; if not, write to the Free Software Foundation, | |
| * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. | |
| * | |
| * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA | |
| * or visit www.oracle.com if you need additional information or have any | |
| * questions. | |
| */ | |
| /* | |
| * @test | |
| * @bug 8243254 | |
| * @summary Tests a simple set of operations on Zip files in various encodings | |
| * focusing on ensuring metadata is properly encoded and read. | |
| * @run testng TestZipFileEncodings | |
| */ | |
| import org.testng.annotations.AfterClass; | |
| import org.testng.annotations.DataProvider; | |
| import org.testng.annotations.Test; | |
| import java.io.BufferedOutputStream; | |
| import java.io.File; | |
| import java.io.FileOutputStream; | |
| import java.io.IOException; | |
| import java.io.InputStream; | |
| import java.nio.charset.Charset; | |
| 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.BasicFileAttributes; | |
| import java.util.ArrayList; | |
| import java.util.HashSet; | |
| import java.util.LinkedHashMap; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.Random; | |
| import java.util.Set; | |
| import java.util.concurrent.ExecutorService; | |
| import java.util.concurrent.Executors; | |
| import java.util.concurrent.ThreadLocalRandom; | |
| import java.util.concurrent.atomic.AtomicInteger; | |
| import java.util.stream.Stream; | |
| import java.util.zip.CRC32; | |
| import java.util.zip.ZipEntry; | |
| import java.util.zip.ZipFile; | |
| import java.util.zip.ZipOutputStream; | |
| import static org.testng.Assert.*; | |
| public class TestZipFileEncodings { | |
| private static int NUM_ENTRIES = 100; | |
| private static int METAINF_ENTRIES = 5; | |
| private static int ENTRY_SIZE = 100; | |
| private static final AtomicInteger SEQUENCE = new AtomicInteger(0); | |
| private static Set<Path> paths = new HashSet<>(); | |
| private static Random random() { | |
| return ThreadLocalRandom.current(); | |
| } | |
| @DataProvider(name = "non-unicode-charsets") | |
| public Object[][] nonUnicodeCharsets() { | |
| return new Object[][] { | |
| { "ISO-8859-1" }, | |
| { "IBM01149" }, | |
| { "IBM037" }, | |
| { "IBM-Thai" } | |
| }; | |
| } | |
| @DataProvider(name = "unicode-charsets") | |
| public Object[][] unicodeCharsets() { | |
| return new Object[][] { | |
| { "UTF-8" }, | |
| { "UTF-16" }, | |
| { "UTF-16LE" }, | |
| { "UTF-16BE" }, | |
| { "UTF-32" } | |
| }; | |
| } | |
| @DataProvider(name = "all-charsets") | |
| public Object[][] allCharsets() { | |
| return Stream.concat(Stream.of(nonUnicodeCharsets()), | |
| Stream.of(unicodeCharsets())) | |
| .toArray(Object[][]::new); | |
| } | |
| @Test(dataProvider = "non-unicode-charsets") | |
| public void testNonUnicode(String charsetName) throws Throwable { | |
| test(NUM_ENTRIES, 100 + random().nextInt(ENTRY_SIZE), false, Charset.forName(charsetName)); | |
| } | |
| @Test(dataProvider = "unicode-charsets") | |
| public void testUnicode(String charsetName) throws Throwable { | |
| test(NUM_ENTRIES, 100 + random().nextInt(ENTRY_SIZE), true, Charset.forName(charsetName)); | |
| } | |
| @Test(dataProvider = "non-unicode-charsets") | |
| public void testNonUnicodeManyEntries(String charsetName) throws Throwable { | |
| test(70000, 10, false, Charset.forName(charsetName)); | |
| } | |
| @Test(dataProvider = "unicode-charsets") | |
| public void testUnicodeManyEntries(String charsetName) throws Throwable { | |
| test(70000, 10, true, Charset.forName(charsetName)); | |
| } | |
| /** | |
| * This test was added to catch a regression where UTFZipCoder incorrectly | |
| * treated latin1-encoded Strings as UTF8-compatible, while this actually only | |
| * holds for ASCII strings. | |
| * | |
| * The implementation of UTFZipCoder.compare was later changed to not depend on | |
| * the String's coder. Let's keep this test around anyway, since it provokes | |
| * a corner case which could be easily missed. | |
| */ | |
| @Test | |
| public void latin1NotAscii() throws IOException { | |
| Path zip = Path.of("latin1-not-ascii.zip"); | |
| // latin1, but not ASCII | |
| String entryName = "smörgåsbord"; | |
| try (ZipOutputStream z = new ZipOutputStream(Files.newOutputStream(zip))) { | |
| z.putNextEntry(new ZipEntry(entryName)); | |
| } | |
| try (ZipFile z = new ZipFile(zip.toFile())) { | |
| assertNotNull(z.getEntry(entryName)); | |
| } | |
| } | |
| /** | |
| * This test was added to catch a regression where ZipCoder.compare did not | |
| * properly verify that the lookup name is a prefix of the entry name. Because of | |
| * this regression, any candidate name with identical lengths and a trailing | |
| * '/' would be incorrectly considered a "directory match". | |
| * | |
| * Since this regression depends on both a hash collision and that the length of names | |
| * are equal, it is rarely found in the wild. Let's keep this test around | |
| * since it explicity provokes this rare condition. | |
| * | |
| */ | |
| @Test(dataProvider = "all-charsets") | |
| public void sameHashAndLengthDirLookup(String charsetName) throws IOException { | |
| // Two directory names with colliding hash codes and same length | |
| // (found in a brute force search) | |
| String one = "_____1637461950/"; | |
| String two = "_____-408231241/"; | |
| // Create a ZIP containing the two directories | |
| Charset charset = Charset.forName(charsetName); | |
| Path zip = Path.of("hash-collision-slashmatch-utf16.zip"); | |
| try (ZipOutputStream z = new ZipOutputStream(Files.newOutputStream(zip), charset)) { | |
| // Give the names different comments so they we can distinguish them | |
| ZipEntry first = new ZipEntry(one); | |
| first.setComment("Entry one"); | |
| z.putNextEntry(first); | |
| ZipEntry second = new ZipEntry(two); | |
| second.setComment("Entry two"); | |
| z.putNextEntry(second); | |
| } | |
| // Assert that "slashless" lookups returns the correct entry even | |
| // when the directory names have colliding hash codes and equal lengths | |
| try (ZipFile z = new ZipFile(zip.toFile(), charset)) { | |
| ZipEntry second = z.getEntry("_____-408231241"); | |
| assertEquals(second.getComment(), "Entry two"); | |
| ZipEntry first = z.getEntry("_____1637461950"); | |
| assertEquals(first.getComment(), "Entry one"); | |
| } | |
| } | |
| @AfterClass | |
| public void tearDown() { | |
| for (Path path : paths) { | |
| path.toFile().deleteOnExit(); | |
| } | |
| } | |
| static void test(int numEntry, int szMax, boolean unicode, Charset cs) throws Throwable { | |
| String name = "zfenc-" + SEQUENCE.incrementAndGet() + ".zip"; | |
| Zip zip = new Zip(name, numEntry, szMax, unicode, cs); | |
| doTest(zip); | |
| } | |
| static void checkEqual(ZipEntry x, ZipEntry y) { | |
| assertEquals(x.getName(), y.getName()); | |
| assertEquals(x.isDirectory(), y.isDirectory()); | |
| assertEquals(x.getMethod(), y.getMethod()); | |
| assertEquals((x.getTime() / 2000), y.getTime() / 2000); | |
| assertEquals(x.getSize(), y.getSize()); | |
| assertEquals(x.getCompressedSize(), y.getCompressedSize()); | |
| assertEquals(x.getCrc(), y.getCrc()); | |
| assertEquals(x.getComment(), y.getComment()); | |
| } | |
| static void doTest(Zip zip) throws Throwable { | |
| try (ZipFile zf = new ZipFile(zip.name, zip.cs)) { | |
| doTest0(zip, zf); | |
| } | |
| } | |
| static void doTest0(Zip zip, ZipFile zf) throws Throwable { | |
| // (0) check zero-length entry name, no AIOOBE | |
| assertEquals(zf.getEntry(""), null); | |
| List<ZipEntry> list = new ArrayList(zip.entries.keySet()); | |
| // check each entry and its bytes | |
| for (ZipEntry ze : list) { | |
| byte[] data = zip.entries.get(ze); | |
| String name = ze.getName(); | |
| ZipEntry e = zf.getEntry(name); | |
| checkEqual(e, ze); | |
| if (!e.isDirectory()) { | |
| // check with readAllBytes | |
| try (InputStream is = zf.getInputStream(e)) { | |
| assertEquals(data, is.readAllBytes()); | |
| } | |
| int slash = name.indexOf('/'); | |
| if (slash > 0) { | |
| ZipEntry dir1 = zf.getEntry(name.substring(0, slash)); | |
| ZipEntry dir2 = zf.getEntry(name.substring(0, slash + 1)); | |
| assertNotNull(dir1); | |
| assertNotNull(dir2); | |
| assertTrue(dir1.isDirectory()); | |
| assertTrue(dir2.isDirectory()); | |
| checkEqual(dir1, dir2); | |
| } | |
| } else { | |
| ZipEntry unslashLookup = zf.getEntry(name.substring(0, name.length() - 1)); | |
| checkEqual(e, unslashLookup); | |
| } | |
| } | |
| } | |
| private static class Zip { | |
| String name; | |
| Charset cs; | |
| Map<ZipEntry, byte[]> entries; | |
| BasicFileAttributes attrs; | |
| long lastModified; | |
| Zip(String name, int num, int szMax, boolean unicode, Charset cs) { | |
| this.cs = cs; | |
| this.name = name; | |
| entries = new LinkedHashMap<>(num); | |
| try { | |
| Path p = Paths.get(name); | |
| Files.deleteIfExists(p); | |
| paths.add(p); | |
| } catch (Exception x) { | |
| throw (RuntimeException)x; | |
| } | |
| try (FileOutputStream fos = new FileOutputStream(name); | |
| BufferedOutputStream bos = new BufferedOutputStream(fos); | |
| ZipOutputStream zos = new ZipOutputStream(bos, cs)) | |
| { | |
| CRC32 crc = new CRC32(); | |
| for (int i = 0; i < num; i++) { | |
| String ename = "entry-" + i + "-name-" + random().nextLong(); | |
| if (unicode) { | |
| // Provokes compatibility issue with slash handling for | |
| // non-ASCII compatible Unicode encodings | |
| ename = ename + '\u2F2F'; | |
| zos.putNextEntry(new ZipEntry(ename + '/')); | |
| ename = ename + '/' + ename; | |
| } | |
| ZipEntry ze = new ZipEntry(ename); | |
| assertTrue(!ze.isDirectory()); | |
| writeEntry(zos, crc, ze, ZipEntry.STORED, szMax); | |
| } | |
| // add some manifest entries | |
| zos.putNextEntry(new ZipEntry("META-INF/")); | |
| for (int i = 0; i < METAINF_ENTRIES; i++) { | |
| String meta = "META-INF/" + "entry-" + i + "-metainf-" + random().nextLong(); | |
| ZipEntry ze = new ZipEntry(meta); | |
| writeEntry(zos, crc, ze, ZipEntry.STORED, szMax); | |
| } | |
| } catch (Exception x) { | |
| throw (RuntimeException)x; | |
| } | |
| try { | |
| this.attrs = Files.readAttributes(Paths.get(name), BasicFileAttributes.class); | |
| this.lastModified = new File(name).lastModified(); | |
| } catch (Exception x) { | |
| throw (RuntimeException)x; | |
| } | |
| } | |
| private void writeEntry(ZipOutputStream zos, CRC32 crc, | |
| ZipEntry ze, int method, int szMax) | |
| throws IOException | |
| { | |
| ze.setMethod(method); | |
| byte[] data = new byte[random().nextInt(szMax + 1)]; | |
| random().nextBytes(data); | |
| if (method == ZipEntry.STORED) { // must set size/csize/crc | |
| ze.setSize(data.length); | |
| ze.setCompressedSize(data.length); | |
| crc.reset(); | |
| crc.update(data); | |
| ze.setCrc(crc.getValue()); | |
| } | |
| ze.setTime(System.currentTimeMillis()); | |
| ze.setComment(ze.getName()); | |
| zos.putNextEntry(ze); | |
| zos.write(data); | |
| zos.closeEntry(); | |
| entries.put(ze, data); | |
| } | |
| } | |
| } |