xref: /aosp_15_r20/external/okio/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt (revision f9742813c14b702d71392179818a9e591da8620c)
1 /*
<lambda>null2  * 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  *     http://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 package okio.internal
18 
19 import java.util.Calendar
20 import java.util.GregorianCalendar
21 import okio.BufferedSource
22 import okio.FileMetadata
23 import okio.FileSystem
24 import okio.IOException
25 import okio.Path
26 import okio.Path.Companion.toPath
27 import okio.ZipFileSystem
28 import okio.buffer
29 
30 private const val LOCAL_FILE_HEADER_SIGNATURE = 0x4034b50
31 private const val CENTRAL_FILE_HEADER_SIGNATURE = 0x2014b50
32 private const val END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x6054b50
33 private const val ZIP64_LOCATOR_SIGNATURE = 0x07064b50
34 private const val ZIP64_EOCD_RECORD_SIGNATURE = 0x06064b50
35 
36 internal const val COMPRESSION_METHOD_DEFLATED = 8
37 internal const val COMPRESSION_METHOD_STORED = 0
38 
39 /** General Purpose Bit Flags, Bit 0. Set if the file is encrypted. */
40 private const val BIT_FLAG_ENCRYPTED = 1 shl 0
41 
42 /**
43  * General purpose bit flags that this implementation handles. Strict enforcement of additional
44  * flags may break legitimate use cases.
45  */
46 private const val BIT_FLAG_UNSUPPORTED_MASK = BIT_FLAG_ENCRYPTED
47 
48 /** Max size of entries and archives without zip64. */
49 private const val MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE = 0xffffffffL
50 
51 private const val HEADER_ID_ZIP64_EXTENDED_INFO = 0x1
52 private const val HEADER_ID_EXTENDED_TIMESTAMP = 0x5455
53 
54 /**
55  * Opens the file at [zipPath] for use as a file system. This uses UTF-8 to comments and names in
56  * the zip file.
57  *
58  * @param predicate a function that returns false for entries that should be omitted from the file
59  *     system.
60  */
61 @Throws(IOException::class)
62 internal fun openZip(
63   zipPath: Path,
64   fileSystem: FileSystem,
65   predicate: (ZipEntry) -> Boolean = { true },
66 ): ZipFileSystem {
fileHandlenull67   fileSystem.openReadOnly(zipPath).use { fileHandle ->
68     // Scan backwards from the end of the file looking for the END_OF_CENTRAL_DIRECTORY_SIGNATURE.
69     // If this file has no comment we'll see it on the first attempt; otherwise we have to go
70     // backwards byte-by-byte until we reach it. (The number of bytes scanned will equal the comment
71     // size).
72     var scanOffset = fileHandle.size() - 22 // end of central directory record size is 22 bytes.
73     if (scanOffset < 0L) {
74       throw IOException("not a zip: size=${fileHandle.size()}")
75     }
76     val stopOffset = maxOf(scanOffset - 65_536L, 0L)
77     val eocdOffset: Long
78     var record: EocdRecord
79     val comment: String
80     while (true) {
81       val source = fileHandle.source(scanOffset).buffer()
82       try {
83         if (source.readIntLe() == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
84           eocdOffset = scanOffset
85           record = source.readEocdRecord()
86           comment = source.readUtf8(record.commentByteCount.toLong())
87           break
88         }
89       } finally {
90         source.close()
91       }
92 
93       scanOffset--
94       if (scanOffset < stopOffset) {
95         throw IOException("not a zip: end of central directory signature not found")
96       }
97     }
98 
99     // If this is a zip64, read a zip64 central directory record.
100     val zip64LocatorOffset = eocdOffset - 20 // zip64 end of central directory locator is 20 bytes.
101     if (zip64LocatorOffset > 0L) {
102       fileHandle.source(zip64LocatorOffset).buffer().use { zip64LocatorSource ->
103         if (zip64LocatorSource.readIntLe() == ZIP64_LOCATOR_SIGNATURE) {
104           val diskWithCentralDir = zip64LocatorSource.readIntLe()
105           val zip64EocdRecordOffset = zip64LocatorSource.readLongLe()
106           val numDisks = zip64LocatorSource.readIntLe()
107           if (numDisks != 1 || diskWithCentralDir != 0) {
108             throw IOException("unsupported zip: spanned")
109           }
110           fileHandle.source(zip64EocdRecordOffset).buffer().use { zip64EocdSource ->
111             val zip64EocdSignature = zip64EocdSource.readIntLe()
112             if (zip64EocdSignature != ZIP64_EOCD_RECORD_SIGNATURE) {
113               throw IOException(
114                 "bad zip: expected ${ZIP64_EOCD_RECORD_SIGNATURE.hex} " +
115                   "but was ${zip64EocdSignature.hex}",
116               )
117             }
118             record = zip64EocdSource.readZip64EocdRecord(record)
119           }
120         }
121       }
122     }
123 
124     // Seek to the first central directory entry and read all of the entries.
125     val entries = mutableListOf<ZipEntry>()
126     fileHandle.source(record.centralDirectoryOffset).buffer().use { source ->
127       for (i in 0 until record.entryCount) {
128         val entry = source.readEntry()
129         if (entry.offset >= record.centralDirectoryOffset) {
130           throw IOException("bad zip: local file header offset >= central directory offset")
131         }
132         if (predicate(entry)) {
133           entries += entry
134         }
135       }
136     }
137 
138     // Organize the entries into a tree.
139     val index = buildIndex(entries)
140 
141     return ZipFileSystem(zipPath, fileSystem, index, comment)
142   }
143 }
144 
145 /**
146  * Returns a map containing all of [entries], plus parent entries required so that all entries
147  * (other than the file system root `/`) have a parent.
148  */
buildIndexnull149 private fun buildIndex(entries: List<ZipEntry>): Map<Path, ZipEntry> {
150   val root = "/".toPath()
151   val result = mutableMapOf(
152     root to ZipEntry(canonicalPath = root, isDirectory = true),
153   )
154 
155   // Iterate in sorted order so each path is preceded by its parent.
156   for (entry in entries.sortedBy { it.canonicalPath }) {
157     // Note that this may clobber an existing element in the map. For consistency with java.util.zip
158     // and java.nio.file.FileSystem, this prefers the last-encountered element.
159     val replaced = result.put(entry.canonicalPath, entry)
160     if (replaced != null) continue
161 
162     // Make sure this parent directories exist all the way up to the file system root.
163     var child = entry
164     while (true) {
165       val parentPath = child.canonicalPath.parent ?: break // child is '/'.
166       var parentEntry = result[parentPath]
167 
168       // We've found a parent that already exists! Add the child; we're done.
169       if (parentEntry != null) {
170         parentEntry.children += child.canonicalPath
171         break
172       }
173 
174       // A parent is missing! Synthesize one.
175       parentEntry = ZipEntry(
176         canonicalPath = parentPath,
177         isDirectory = true,
178       )
179       result[parentPath] = parentEntry
180       parentEntry.children += child.canonicalPath
181       child = parentEntry
182     }
183   }
184 
185   return result
186 }
187 
188 /** When this returns, [this] will be positioned at the start of the next entry. */
189 @Throws(IOException::class)
readEntrynull190 internal fun BufferedSource.readEntry(): ZipEntry {
191   val signature = readIntLe()
192   if (signature != CENTRAL_FILE_HEADER_SIGNATURE) {
193     throw IOException(
194       "bad zip: expected ${CENTRAL_FILE_HEADER_SIGNATURE.hex} but was ${signature.hex}",
195     )
196   }
197 
198   skip(4) // version made by (2) + version to extract (2).
199   val bitFlag = readShortLe().toInt() and 0xffff
200   if (bitFlag and BIT_FLAG_UNSUPPORTED_MASK != 0) {
201     throw IOException("unsupported zip: general purpose bit flag=${bitFlag.hex}")
202   }
203 
204   val compressionMethod = readShortLe().toInt() and 0xffff
205   val time = readShortLe().toInt() and 0xffff
206   val date = readShortLe().toInt() and 0xffff
207   // TODO(jwilson): decode NTFS and UNIX extra metadata to return better timestamps.
208   val lastModifiedAtMillis = dosDateTimeToEpochMillis(date, time)
209 
210   // These are 32-bit values in the file, but 64-bit fields in this object.
211   val crc = readIntLe().toLong() and 0xffffffffL
212   var compressedSize = readIntLe().toLong() and 0xffffffffL
213   var size = readIntLe().toLong() and 0xffffffffL
214   val nameSize = readShortLe().toInt() and 0xffff
215   val extraSize = readShortLe().toInt() and 0xffff
216   val commentByteCount = readShortLe().toInt() and 0xffff
217 
218   skip(8) // disk number start (2) + internal file attributes (2) + external file attributes (4).
219   var offset = readIntLe().toLong() and 0xffffffffL
220   val name = readUtf8(nameSize.toLong())
221   if ('\u0000' in name) throw IOException("bad zip: filename contains 0x00")
222 
223   val requiredZip64ExtraSize = run {
224     var result = 0L
225     if (size == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) result += 8
226     if (compressedSize == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) result += 8
227     if (offset == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) result += 8
228     return@run result
229   }
230 
231   var hasZip64Extra = false
232   readExtra(extraSize) { headerId, dataSize ->
233     when (headerId) {
234       HEADER_ID_ZIP64_EXTENDED_INFO -> {
235         if (hasZip64Extra) {
236           throw IOException("bad zip: zip64 extra repeated")
237         }
238         hasZip64Extra = true
239 
240         if (dataSize < requiredZip64ExtraSize) {
241           throw IOException("bad zip: zip64 extra too short")
242         }
243 
244         // Read each field if it has a sentinel value in the regular header.
245         size = if (size == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) readLongLe() else size
246         compressedSize = if (compressedSize == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) readLongLe() else 0L
247         offset = if (offset == MAX_ZIP_ENTRY_AND_ARCHIVE_SIZE) readLongLe() else 0L
248       }
249     }
250   }
251 
252   if (requiredZip64ExtraSize > 0L && !hasZip64Extra) {
253     throw IOException("bad zip: zip64 extra required but absent")
254   }
255 
256   val comment = readUtf8(commentByteCount.toLong())
257   val canonicalPath = "/".toPath() / name
258   val isDirectory = name.endsWith("/")
259 
260   return ZipEntry(
261     canonicalPath = canonicalPath,
262     isDirectory = isDirectory,
263     comment = comment,
264     crc = crc,
265     compressedSize = compressedSize,
266     size = size,
267     compressionMethod = compressionMethod,
268     lastModifiedAtMillis = lastModifiedAtMillis,
269     offset = offset,
270   )
271 }
272 
273 @Throws(IOException::class)
readEocdRecordnull274 private fun BufferedSource.readEocdRecord(): EocdRecord {
275   val diskNumber = readShortLe().toInt() and 0xffff
276   val diskWithCentralDir = readShortLe().toInt() and 0xffff
277   val entryCount = (readShortLe().toInt() and 0xffff).toLong()
278   val totalEntryCount = (readShortLe().toInt() and 0xffff).toLong()
279   if (entryCount != totalEntryCount || diskNumber != 0 || diskWithCentralDir != 0) {
280     throw IOException("unsupported zip: spanned")
281   }
282   skip(4) // central directory size.
283   val centralDirectoryOffset = readIntLe().toLong() and 0xffffffffL
284   val commentByteCount = readShortLe().toInt() and 0xffff
285 
286   return EocdRecord(
287     entryCount = entryCount,
288     centralDirectoryOffset = centralDirectoryOffset,
289     commentByteCount = commentByteCount,
290   )
291 }
292 
293 @Throws(IOException::class)
readZip64EocdRecordnull294 private fun BufferedSource.readZip64EocdRecord(regularRecord: EocdRecord): EocdRecord {
295   skip(12) // size of central directory record (8) + version made by (2) + version to extract (2).
296   val diskNumber = readIntLe()
297   val diskWithCentralDirStart = readIntLe()
298   val entryCount = readLongLe()
299   val totalEntryCount = readLongLe()
300   if (entryCount != totalEntryCount || diskNumber != 0 || diskWithCentralDirStart != 0) {
301     throw IOException("unsupported zip: spanned")
302   }
303   skip(8) // central directory size.
304   val centralDirectoryOffset = readLongLe()
305 
306   return EocdRecord(
307     entryCount = entryCount,
308     centralDirectoryOffset = centralDirectoryOffset,
309     commentByteCount = regularRecord.commentByteCount,
310   )
311 }
312 
313 /**
314  * Read a sequence of 0 or more extra fields. Each field has this structure:
315  *
316  *  * 2-byte header ID
317  *  * 2-byte data size
318  *  * variable-byte data value
319  *
320  * This reads each extra field and calls [block] for each. The parameters are the header ID and
321  * data size. It is an error for [block] to process more bytes than the data size.
322  */
BufferedSourcenull323 private fun BufferedSource.readExtra(extraSize: Int, block: (Int, Long) -> Unit) {
324   var remaining = extraSize.toLong()
325   while (remaining != 0L) {
326     if (remaining < 4) {
327       throw IOException("bad zip: truncated header in extra field")
328     }
329     val headerId = readShortLe().toInt() and 0xffff
330     val dataSize = readShortLe().toLong() and 0xffff
331     remaining -= 4
332     if (remaining < dataSize) {
333       throw IOException("bad zip: truncated value in extra field")
334     }
335     require(dataSize)
336     val sizeBefore = buffer.size
337     block(headerId, dataSize)
338     val fieldRemaining = dataSize + buffer.size - sizeBefore
339     when {
340       fieldRemaining < 0 -> {
341         throw IOException("unsupported zip: too many bytes processed for $headerId")
342       }
343       fieldRemaining > 0 -> {
344         buffer.skip(fieldRemaining)
345       }
346     }
347     remaining -= dataSize
348   }
349 }
350 
skipLocalHeadernull351 internal fun BufferedSource.skipLocalHeader() {
352   readOrSkipLocalHeader(null)
353 }
354 
readLocalHeadernull355 internal fun BufferedSource.readLocalHeader(basicMetadata: FileMetadata): FileMetadata {
356   return readOrSkipLocalHeader(basicMetadata)!!
357 }
358 
359 /**
360  * If [basicMetadata] is null this will return null. Otherwise it will return a new header which
361  * updates [basicMetadata] with information from the local header.
362  */
readOrSkipLocalHeadernull363 private fun BufferedSource.readOrSkipLocalHeader(basicMetadata: FileMetadata?): FileMetadata? {
364   var lastModifiedAtMillis = basicMetadata?.lastModifiedAtMillis
365   var lastAccessedAtMillis: Long? = null
366   var createdAtMillis: Long? = null
367 
368   val signature = readIntLe()
369   if (signature != LOCAL_FILE_HEADER_SIGNATURE) {
370     throw IOException(
371       "bad zip: expected ${LOCAL_FILE_HEADER_SIGNATURE.hex} but was ${signature.hex}",
372     )
373   }
374   skip(2) // version to extract.
375   val bitFlag = readShortLe().toInt() and 0xffff
376   if (bitFlag and BIT_FLAG_UNSUPPORTED_MASK != 0) {
377     throw IOException("unsupported zip: general purpose bit flag=${bitFlag.hex}")
378   }
379   skip(18) // compression method (2) + time+date (4) + crc32 (4) + compressed size (4) + size (4).
380   val fileNameLength = readShortLe().toLong() and 0xffff
381   val extraSize = readShortLe().toInt() and 0xffff
382   skip(fileNameLength)
383 
384   if (basicMetadata == null) {
385     skip(extraSize.toLong())
386     return null
387   }
388 
389   readExtra(extraSize) { headerId, dataSize ->
390     when (headerId) {
391       HEADER_ID_EXTENDED_TIMESTAMP -> {
392         if (dataSize < 1) {
393           throw IOException("bad zip: extended timestamp extra too short")
394         }
395         val flags = readByte().toInt() and 0xff
396 
397         val hasLastModifiedAtMillis = (flags and 0x1) == 0x1
398         val hasLastAccessedAtMillis = (flags and 0x2) == 0x2
399         val hasCreatedAtMillis = (flags and 0x4) == 0x4
400         val requiredSize = run {
401           var result = 1L
402           if (hasLastModifiedAtMillis) result += 4L
403           if (hasLastAccessedAtMillis) result += 4L
404           if (hasCreatedAtMillis) result += 4L
405           return@run result
406         }
407         if (dataSize < requiredSize) {
408           throw IOException("bad zip: extended timestamp extra too short")
409         }
410 
411         if (hasLastModifiedAtMillis) lastModifiedAtMillis = readIntLe() * 1000L
412         if (hasLastAccessedAtMillis) lastAccessedAtMillis = readIntLe() * 1000L
413         if (hasCreatedAtMillis) createdAtMillis = readIntLe() * 1000L
414       }
415     }
416   }
417 
418   return FileMetadata(
419     isRegularFile = basicMetadata.isRegularFile,
420     isDirectory = basicMetadata.isDirectory,
421     symlinkTarget = null,
422     size = basicMetadata.size,
423     createdAtMillis = createdAtMillis,
424     lastModifiedAtMillis = lastModifiedAtMillis,
425     lastAccessedAtMillis = lastAccessedAtMillis,
426   )
427 }
428 
429 /**
430  * Converts a 32-bit DOS date+time to milliseconds since epoch. Note that this function interprets
431  * a value with no time zone as a value with the local time zone.
432  */
dosDateTimeToEpochMillisnull433 private fun dosDateTimeToEpochMillis(date: Int, time: Int): Long? {
434   if (time == -1) {
435     return null
436   }
437 
438   // Note that this inherits the local time zone.
439   val cal = GregorianCalendar()
440   cal.set(Calendar.MILLISECOND, 0)
441   val year = 1980 + (date shr 9 and 0x7f)
442   val month = date shr 5 and 0xf
443   val day = date and 0x1f
444   val hour = time shr 11 and 0x1f
445   val minute = time shr 5 and 0x3f
446   val second = time and 0x1f shl 1
447   cal.set(year, month - 1, day, hour, minute, second)
448   return cal.time.time
449 }
450 
451 private class EocdRecord(
452   val entryCount: Long,
453   val centralDirectoryOffset: Long,
454   val commentByteCount: Int,
455 )
456 
457 private val Int.hex: String
458   get() = "0x${this.toString(16)}"
459