From 940496a3d172f1368ea719b5af022b6fa0f92f1b Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Mon, 4 Mar 2024 12:13:54 -0500 Subject: [PATCH] Get ZipFileSystem to prefer NTFS timestamps when present (#1449) * Get ZipFileSystem to prefer NTFS timestamps when present They don't have a time bomb in 2038. * Add a missing import But why didn't the compiler get mad at me? --- .../src/commonMain/resources/go/NOTICE | 5 + .../src/archive/zip/testdata/time-winzip.zip | Bin 0 -> 150 bytes .../resources/okio/zipfilesystem/emptyZip.zip | Bin 0 -> 22 bytes ...mpty.zip => emptyZipWithPrependedData.zip} | Bin .../src/zlibMain/kotlin/okio/ZipFileSystem.kt | 69 ++++------- .../zlibMain/kotlin/okio/internal/ZipEntry.kt | 90 ++++++++++++++- .../zlibMain/kotlin/okio/internal/ZipFiles.kt | 108 +++++++++++++----- .../kotlin/okio/ZipFileSystemGoTest.kt | 50 ++++++++ .../zlibTest/kotlin/okio/ZipFileSystemTest.kt | 24 +--- 9 files changed, 243 insertions(+), 103 deletions(-) create mode 100644 okio-testing-support/src/commonMain/resources/go/NOTICE create mode 100644 okio-testing-support/src/commonMain/resources/go/src/archive/zip/testdata/time-winzip.zip create mode 100644 okio-testing-support/src/commonMain/resources/okio/zipfilesystem/emptyZip.zip rename okio-testing-support/src/commonMain/resources/okio/zipfilesystem/{empty.zip => emptyZipWithPrependedData.zip} (100%) create mode 100644 okio/src/zlibTest/kotlin/okio/ZipFileSystemGoTest.kt diff --git a/okio-testing-support/src/commonMain/resources/go/NOTICE b/okio-testing-support/src/commonMain/resources/go/NOTICE new file mode 100644 index 0000000000..7b0c14aca7 --- /dev/null +++ b/okio-testing-support/src/commonMain/resources/go/NOTICE @@ -0,0 +1,5 @@ +The files in this directory are copied from Go: +https://go.dev/ + +These files are subject to the 3-Clause BSD License: +https://github.com/golang/go/blob/master/LICENSE diff --git a/okio-testing-support/src/commonMain/resources/go/src/archive/zip/testdata/time-winzip.zip b/okio-testing-support/src/commonMain/resources/go/src/archive/zip/testdata/time-winzip.zip new file mode 100644 index 0000000000000000000000000000000000000000..f6e8f8ba067e462fe7a9727159390919571b8270 GIT binary patch literal 150 zcmWIWW@h1H0D;<-@!nttl;8l;C8@jkM&B_K+#0Z2@Kw1r~0|1@$8R7r{ literal 0 HcmV?d00001 diff --git a/okio-testing-support/src/commonMain/resources/okio/zipfilesystem/emptyZip.zip b/okio-testing-support/src/commonMain/resources/okio/zipfilesystem/emptyZip.zip new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7 GIT binary patch literal 22 NcmWIWW@Tf*000g10H*)| literal 0 HcmV?d00001 diff --git a/okio-testing-support/src/commonMain/resources/okio/zipfilesystem/empty.zip b/okio-testing-support/src/commonMain/resources/okio/zipfilesystem/emptyZipWithPrependedData.zip similarity index 100% rename from okio-testing-support/src/commonMain/resources/okio/zipfilesystem/empty.zip rename to okio-testing-support/src/commonMain/resources/okio/zipfilesystem/emptyZipWithPrependedData.zip diff --git a/okio/src/zlibMain/kotlin/okio/ZipFileSystem.kt b/okio/src/zlibMain/kotlin/okio/ZipFileSystem.kt index 2660ae44d7..c0ce35af1a 100644 --- a/okio/src/zlibMain/kotlin/okio/ZipFileSystem.kt +++ b/okio/src/zlibMain/kotlin/okio/ZipFileSystem.kt @@ -26,37 +26,6 @@ import okio.internal.skipLocalHeader /** * Read only access to a [zip file][zip_format] and common [extra fields][extra_fields]. * - * Zip Timestamps - * -------------- - * - * The base zip format tracks the [last modified timestamp][FileMetadata.lastModifiedAtMillis]. It - * does not track [created timestamps][FileMetadata.createdAtMillis] or [last accessed - * timestamps][FileMetadata.lastAccessedAtMillis]. This format has limitations: - * - * * Timestamps are 16-bit values stored with 2-second precision. Some zip encoders (WinZip, PKZIP) - * round up to the nearest 2 seconds; other encoders (Java) round down. - * - * * Timestamps before 1980-01-01 cannot be represented. They cannot represent dates after - * 2107-12-31. - * - * * Timestamps are stored in local time with no time zone offset. If the time zone offset changes - * – due to daylight savings time or the zip file being sent to another time zone – file times - * will be incorrect. The file time will be shifted by the difference in time zone offsets - * between the encoder and decoder. - * - * The zip format has optional extensions for timestamps. - * - * * UNIX timestamps (0x000d) support both last-access time and last modification time. These - * timestamps are stored with 1-second precision using UTC. - * - * * NTFS timestamps (0x000a) support creation time, last access time, and last modified time. - * These timestamps are stored with 100-millisecond precision using UTC. - * - * * Extended timestamps (0x5455) are stored as signed 32-bit timestamps with 1-second precision. - * These cannot express dates beyond 2038-01-19. - * - * This class currently supports base timestamps and extended timestamps. - * * [zip_format]: https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE_6.2.0.txt * [extra_fields]: https://opensource.apple.com/source/zip/zip-6/unzip/unzip/proginfo/extra.fld */ @@ -81,27 +50,29 @@ internal class ZipFileSystem internal constructor( override fun metadataOrNull(path: Path): FileMetadata? { val canonicalPath = canonicalizeInternal(path) - val entry = entries[canonicalPath] ?: return null - - val basicMetadata = FileMetadata( - isRegularFile = !entry.isDirectory, - isDirectory = entry.isDirectory, - symlinkTarget = null, - size = if (entry.isDirectory) null else entry.size, - createdAtMillis = null, - lastModifiedAtMillis = entry.lastModifiedAtMillis, - lastAccessedAtMillis = null, - ) + val centralDirectoryEntry = entries[canonicalPath] ?: return null + + val fullEntry = when { + centralDirectoryEntry.offset != -1L -> { + fileSystem.openReadOnly(zipPath).use { fileHandle -> + return@use fileHandle.source(centralDirectoryEntry.offset).buffer().use { source -> + source.readLocalHeader(centralDirectoryEntry) + } + } + } - if (entry.offset == -1L) { - return basicMetadata + else -> centralDirectoryEntry } - return fileSystem.openReadOnly(zipPath).use { fileHandle -> - return@use fileHandle.source(entry.offset).buffer().use { source -> - source.readLocalHeader(basicMetadata) - } - } + return FileMetadata( + isRegularFile = !fullEntry.isDirectory, + isDirectory = fullEntry.isDirectory, + symlinkTarget = null, + size = if (fullEntry.isDirectory) null else fullEntry.size, + createdAtMillis = fullEntry.createdAtMillis, + lastModifiedAtMillis = fullEntry.lastModifiedAtMillis, + lastAccessedAtMillis = fullEntry.lastAccessedAtMillis, + ) } override fun openReadOnly(file: Path): FileHandle { diff --git a/okio/src/zlibMain/kotlin/okio/internal/ZipEntry.kt b/okio/src/zlibMain/kotlin/okio/internal/ZipEntry.kt index a370aca84a..4050b9188a 100644 --- a/okio/src/zlibMain/kotlin/okio/internal/ZipEntry.kt +++ b/okio/src/zlibMain/kotlin/okio/internal/ZipEntry.kt @@ -16,8 +16,12 @@ */ package okio.internal +import okio.FileMetadata import okio.Path +/** + * This class prefers NTFS timestamps, then extended timestamps, then the base ZIP timestamps. + */ internal class ZipEntry( /** * Absolute path of this entry. If the raw name on disk contains relative paths like `..`, they @@ -43,9 +47,91 @@ internal class ZipEntry( /** Either [COMPRESSION_METHOD_DEFLATED] or [COMPRESSION_METHOD_STORED]. */ val compressionMethod: Int = -1, - val lastModifiedAtMillis: Long? = null, - val offset: Long = -1L, + + /** + * The base ZIP format tracks the [last modified timestamp][FileMetadata.lastModifiedAtMillis]. It + * does not track [created timestamps][FileMetadata.createdAtMillis] or [last accessed + * timestamps][FileMetadata.lastAccessedAtMillis]. + * + * This format has severe limitations: + * + * * Timestamps are 16-bit values stored with 2-second precision. Some zip encoders (WinZip, + * PKZIP) round up to the nearest 2 seconds; other encoders (Java) round down. + * + * * Timestamps before 1980-01-01 cannot be represented. They cannot represent dates after + * 2107-12-31. + * + * * Timestamps are stored in local time with no time zone offset. If the time zone offset + * changes – due to daylight savings time or the zip file being sent to another time zone – + * file times will be incorrect. The file time will be shifted by the difference in time zone + * offsets between the encoder and decoder. + */ + val dosLastModifiedAtDate: Int = -1, + val dosLastModifiedAtTime: Int = -1, + + /** + * NTFS timestamps (0x000a) support creation time, last access time, and last modified time. + * These timestamps are stored with 100-millisecond precision using UTC. + */ + val ntfsLastModifiedAtFiletime: Long? = null, + val ntfsLastAccessedAtFiletime: Long? = null, + val ntfsCreatedAtFiletime: Long? = null, + + /** + * Extended timestamps (0x5455) are stored as signed 32-bit timestamps with 1-second precision. + * These cannot express dates beyond 2038-01-19. + */ + val extendedLastModifiedAtSeconds: Int? = null, + val extendedLastAccessedAtSeconds: Int? = null, + val extendedCreatedAtSeconds: Int? = null, ) { val children = mutableListOf() + + internal fun copy( + extendedLastModifiedAtSeconds: Int?, + extendedLastAccessedAtSeconds: Int?, + extendedCreatedAtSeconds: Int?, + ) = ZipEntry( + canonicalPath = canonicalPath, + isDirectory = isDirectory, + comment = comment, + crc = crc, + compressedSize = compressedSize, + size = size, + compressionMethod = compressionMethod, + offset = offset, + dosLastModifiedAtDate = dosLastModifiedAtDate, + dosLastModifiedAtTime = dosLastModifiedAtTime, + ntfsLastModifiedAtFiletime = ntfsLastModifiedAtFiletime, + ntfsLastAccessedAtFiletime = ntfsLastAccessedAtFiletime, + ntfsCreatedAtFiletime = ntfsCreatedAtFiletime, + extendedLastModifiedAtSeconds = extendedLastModifiedAtSeconds, + extendedLastAccessedAtSeconds = extendedLastAccessedAtSeconds, + extendedCreatedAtSeconds = extendedCreatedAtSeconds, + ) + + internal val lastAccessedAtMillis: Long? + get() = when { + ntfsLastAccessedAtFiletime != null -> filetimeToEpochMillis(ntfsLastAccessedAtFiletime) + extendedLastAccessedAtSeconds != null -> extendedLastAccessedAtSeconds * 1000L + else -> null + } + + internal val lastModifiedAtMillis: Long? + get() = when { + ntfsLastModifiedAtFiletime != null -> filetimeToEpochMillis(ntfsLastModifiedAtFiletime) + extendedLastModifiedAtSeconds != null -> extendedLastModifiedAtSeconds * 1000L + dosLastModifiedAtTime != -1 -> { + dosDateTimeToEpochMillis(dosLastModifiedAtDate, dosLastModifiedAtTime) + } + else -> null + } + + internal val createdAtMillis: Long? + get() = when { + ntfsCreatedAtFiletime != null -> filetimeToEpochMillis(ntfsCreatedAtFiletime) + extendedCreatedAtSeconds != null -> extendedCreatedAtSeconds * 1000L + else -> null + } } diff --git a/okio/src/zlibMain/kotlin/okio/internal/ZipFiles.kt b/okio/src/zlibMain/kotlin/okio/internal/ZipFiles.kt index 201a7e6e16..5b1348f227 100644 --- a/okio/src/zlibMain/kotlin/okio/internal/ZipFiles.kt +++ b/okio/src/zlibMain/kotlin/okio/internal/ZipFiles.kt @@ -17,7 +17,6 @@ package okio.internal import okio.BufferedSource -import okio.FileMetadata import okio.FileSystem import okio.IOException import okio.Path @@ -48,6 +47,7 @@ private const val BIT_FLAG_UNSUPPORTED_MASK = BIT_FLAG_ENCRYPTED private const val MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE = 0xffffffffL private const val HEADER_ID_ZIP64_EXTENDED_INFO = 0x1 +private const val HEADER_ID_NTFS_EXTRA = 0x000a private const val HEADER_ID_EXTENDED_TIMESTAMP = 0x5455 /** @@ -124,7 +124,7 @@ internal fun openZip( val entries = mutableListOf() fileHandle.source(record.centralDirectoryOffset).buffer().use { source -> for (i in 0 until record.entryCount) { - val entry = source.readEntry() + val entry = source.readCentralDirectoryZipEntry() if (entry.offset >= record.centralDirectoryOffset) { throw IOException("bad zip: local file header offset >= central directory offset") } @@ -186,7 +186,7 @@ private fun buildIndex(entries: List): Map { /** When this returns, [this] will be positioned at the start of the next entry. */ @Throws(IOException::class) -internal fun BufferedSource.readEntry(): ZipEntry { +internal fun BufferedSource.readCentralDirectoryZipEntry(): ZipEntry { val signature = readIntLe() if (signature != CENTRAL_FILE_HEADER_SIGNATURE) { throw IOException( @@ -201,10 +201,8 @@ internal fun BufferedSource.readEntry(): ZipEntry { } val compressionMethod = readShortLe().toInt() and 0xffff - val time = readShortLe().toInt() and 0xffff - val date = readShortLe().toInt() and 0xffff - // TODO(jwilson): decode NTFS and UNIX extra metadata to return better timestamps. - val lastModifiedAtMillis = dosDateTimeToEpochMillis(date, time) + val dosLastModifiedTime = readShortLe().toInt() and 0xffff + val dosLastModifiedDate = readShortLe().toInt() and 0xffff // These are 32-bit values in the file, but 64-bit fields in this object. val crc = readIntLe().toLong() and 0xffffffffL @@ -227,6 +225,10 @@ internal fun BufferedSource.readEntry(): ZipEntry { return@run result } + var ntfsLastModifiedAtFiletime: Long? = null + var ntfsLastAccessedAtFiletime: Long? = null + var ntfsCreatedAtFiletime: Long? = null + var hasZip64Extra = false readExtra(extraSize) { headerId, dataSize -> when (headerId) { @@ -245,6 +247,33 @@ internal fun BufferedSource.readEntry(): ZipEntry { compressedSize = if (compressedSize == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) readLongLe() else 0L offset = if (offset == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) readLongLe() else 0L } + + HEADER_ID_NTFS_EXTRA -> { + if (dataSize < 4L) { + throw IOException("bad zip: NTFS extra too short") + } + skip(4L) + + // Reads the NTFS extra metadata. This metadata recursively does a tag and length scheme + // inside of ZIP extras' own tag and length scheme. So we do readExtra() again. + readExtra((dataSize - 4L).toInt()) { attributeId, attributeSize -> + when (attributeId) { + 0x1 -> { + if (ntfsLastModifiedAtFiletime != null) { + throw IOException("bad zip: NTFS extra attribute tag 0x0001 repeated") + } + + if (attributeSize != 24L) { + throw IOException("bad zip: NTFS extra attribute tag 0x0001 size != 24") + } + + ntfsLastModifiedAtFiletime = readLongLe() + ntfsLastAccessedAtFiletime = readLongLe() + ntfsCreatedAtFiletime = readLongLe() + } + } + } + } } } @@ -264,8 +293,12 @@ internal fun BufferedSource.readEntry(): ZipEntry { compressedSize = compressedSize, size = size, compressionMethod = compressionMethod, - lastModifiedAtMillis = lastModifiedAtMillis, offset = offset, + dosLastModifiedAtDate = dosLastModifiedDate, + dosLastModifiedAtTime = dosLastModifiedTime, + ntfsLastModifiedAtFiletime = ntfsLastModifiedAtFiletime, + ntfsLastAccessedAtFiletime = ntfsLastAccessedAtFiletime, + ntfsCreatedAtFiletime = ntfsCreatedAtFiletime, ) } @@ -351,19 +384,17 @@ internal fun BufferedSource.skipLocalHeader() { readOrSkipLocalHeader(null) } -internal fun BufferedSource.readLocalHeader(basicMetadata: FileMetadata): FileMetadata { - return readOrSkipLocalHeader(basicMetadata)!! +internal fun BufferedSource.readLocalHeader(centralDirectoryZipEntry: ZipEntry): ZipEntry { + return readOrSkipLocalHeader(centralDirectoryZipEntry)!! } /** - * If [basicMetadata] is null this will return null. Otherwise it will return a new header which - * updates [basicMetadata] with information from the local header. + * If [centralDirectoryZipEntry] is null this will return null. Otherwise, it will return a new + * entry which unions [centralDirectoryZipEntry] with information from the local header. */ -private fun BufferedSource.readOrSkipLocalHeader(basicMetadata: FileMetadata?): FileMetadata? { - var lastModifiedAtMillis = basicMetadata?.lastModifiedAtMillis - var lastAccessedAtMillis: Long? = null - var createdAtMillis: Long? = null - +private fun BufferedSource.readOrSkipLocalHeader( + centralDirectoryZipEntry: ZipEntry?, +): ZipEntry? { val signature = readIntLe() if (signature != LOCAL_FILE_HEADER_SIGNATURE) { throw IOException( @@ -380,11 +411,15 @@ private fun BufferedSource.readOrSkipLocalHeader(basicMetadata: FileMetadata?): val extraSize = readShortLe().toInt() and 0xffff skip(fileNameLength) - if (basicMetadata == null) { + if (centralDirectoryZipEntry == null) { skip(extraSize.toLong()) return null } + var extendedLastModifiedAtSeconds: Int? = null + var extendedLastAccessedAtSeconds: Int? = null + var extendedCreatedAtSeconds: Int? = null + readExtra(extraSize) { headerId, dataSize -> when (headerId) { HEADER_ID_EXTENDED_TIMESTAMP -> { @@ -407,29 +442,42 @@ private fun BufferedSource.readOrSkipLocalHeader(basicMetadata: FileMetadata?): throw IOException("bad zip: extended timestamp extra too short") } - if (hasLastModifiedAtMillis) lastModifiedAtMillis = readIntLe() * 1000L - if (hasLastAccessedAtMillis) lastAccessedAtMillis = readIntLe() * 1000L - if (hasCreatedAtMillis) createdAtMillis = readIntLe() * 1000L + if (hasLastModifiedAtMillis) extendedLastModifiedAtSeconds = readIntLe() + if (hasLastAccessedAtMillis) extendedLastAccessedAtSeconds = readIntLe() + if (hasCreatedAtMillis) extendedCreatedAtSeconds = readIntLe() } } } - return FileMetadata( - isRegularFile = basicMetadata.isRegularFile, - isDirectory = basicMetadata.isDirectory, - symlinkTarget = null, - size = basicMetadata.size, - createdAtMillis = createdAtMillis, - lastModifiedAtMillis = lastModifiedAtMillis, - lastAccessedAtMillis = lastAccessedAtMillis, + return centralDirectoryZipEntry.copy( + extendedLastModifiedAtSeconds = extendedLastModifiedAtSeconds, + extendedLastAccessedAtSeconds = extendedLastAccessedAtSeconds, + extendedCreatedAtSeconds = extendedCreatedAtSeconds, ) } +/** + * Converts from the Microsoft [filetime] format to the Java epoch millis format. + * + * * Filetime's unit is 100 nanoseconds, and 0 is 1601-01-01T00:00:00Z. + * * Java epoch millis' unit is 1 millisecond, and 0 is 1970-01-01T00:00:00Z. + * + * See also https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-filetime + */ +internal fun filetimeToEpochMillis(filetime: Long): Long { + // There's 11,644,473,600,000 milliseconds between 1601-01-01T00:00:00Z and 1970-01-01T00:00:00Z. + // val years = 1_970 − 1_601 + // val leapYears = floor(years / 4) − floor(years / 100) + // val days = (years * 365) + leapYears + // val millis = days * 24 * 60 * 60 * 1_000 + return filetime / 10_000 - 11_644_473_600_000L +} + /** * Converts a 32-bit DOS date+time to milliseconds since epoch. Note that this function interprets * a value with no time zone as a value with the local time zone. */ -private fun dosDateTimeToEpochMillis(date: Int, time: Int): Long? { +internal fun dosDateTimeToEpochMillis(date: Int, time: Int): Long? { if (time == -1) { return null } diff --git a/okio/src/zlibTest/kotlin/okio/ZipFileSystemGoTest.kt b/okio/src/zlibTest/kotlin/okio/ZipFileSystemGoTest.kt new file mode 100644 index 0000000000..57f35e18e4 --- /dev/null +++ b/okio/src/zlibTest/kotlin/okio/ZipFileSystemGoTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed 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 + * + * http://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 okio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.Instant +import okio.Path.Companion.toPath + +/** + * Test using sample data from Go's test suite. + * + * https://github.com/golang/go/blob/6f5d77454e31be8af11a7e2bcda36d200fda07c5/src/archive/zip/reader_test.go + */ +class ZipFileSystemGoTest { + private val fileSystem = SYSTEM_FILE_SYSTEM + private var base = okioRoot / "okio-testing-support" / + "src/commonMain/resources/go/src/archive/zip/testdata" + + @Test + fun timeWinzip() { + val zipFileSystem = fileSystem.openZip(base / "time-winzip.zip") + val fileMetadata = zipFileSystem.metadata("test.txt".toPath()) + assertEquals( + Instant.parse("2017-11-01T04:11:57.244Z"), + Instant.fromEpochMilliseconds(fileMetadata.createdAtMillis!!), + ) + assertEquals( + Instant.parse("2017-11-01T04:11:57.244Z"), + Instant.fromEpochMilliseconds(fileMetadata.lastModifiedAtMillis!!), + ) + assertEquals( + Instant.parse("2017-11-01T04:13:19.623Z"), + Instant.fromEpochMilliseconds(fileMetadata.lastAccessedAtMillis!!), + ) + } +} diff --git a/okio/src/zlibTest/kotlin/okio/ZipFileSystemTest.kt b/okio/src/zlibTest/kotlin/okio/ZipFileSystemTest.kt index 069955b930..36fcd31358 100644 --- a/okio/src/zlibTest/kotlin/okio/ZipFileSystemTest.kt +++ b/okio/src/zlibTest/kotlin/okio/ZipFileSystemTest.kt @@ -26,11 +26,9 @@ import assertk.assertions.isLessThan import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isTrue -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith import kotlinx.datetime.Instant -import okio.ByteString.Companion.decodeHex import okio.ByteString.Companion.encodeUtf8 import okio.Path.Companion.toPath @@ -38,33 +36,15 @@ class ZipFileSystemTest { private val fileSystem = SYSTEM_FILE_SYSTEM private var base = okioRoot / "okio-testing-support/src/commonMain/resources/okio/zipfilesystem" - @BeforeTest - fun setUp() { - fileSystem.createDirectory(base) - } - @Test fun emptyZip() { - // ZipBuilder cannot write empty zips. - val zipPath = base / "empty.zip" - fileSystem.write(zipPath) { - write("504b0506000000000000000000000000000000000000".decodeHex()) - } - - val zipFileSystem = fileSystem.openZip(zipPath) + val zipFileSystem = fileSystem.openZip(base / "emptyZip.zip") assertThat(zipFileSystem.list("/".toPath())).isEmpty() } @Test fun emptyZipWithPrependedData() { - // ZipBuilder cannot write empty zips. - val zipPath = base / "empty.zip" - fileSystem.write(zipPath) { - writeUtf8("Hello I'm junk data prepended to the ZIP!") - write("504b0506000000000000000000000000000000000000".decodeHex()) - } - - val zipFileSystem = fileSystem.openZip(zipPath) + val zipFileSystem = fileSystem.openZip(base / "emptyZipWithPrependedData.zip") assertThat(zipFileSystem.list("/".toPath())).isEmpty() }