Support 7z extraction, and fix .xz extraction which would have crashed at runtime
Commons Compress declares its LZMA/LZMA2 support against org.tukaani:xz as an *optional* dependency, so nothing pulled it in transitively and nothing failed at build time. The app nevertheless advertised .xz as extractable, so the first user to open one would have got NoClassDefFoundError: org/tukaani/xz/FilterOptions. XZCompressorInputStream references org/tukaani/xz/XZInputStream directly. The library is now declared, and a round-trip test covers the path that was silently missing. That same library is what 7z needs, and Commons Compress already ships a pure-Java 7z reader, so 7z extraction costs no native code and no further dependency. The docs had assumed the format was off limits and said so in three places, including the customer-facing store copy. 7z is the only format here that cannot arrive through a stream: its header sits at the end of the file, so the reader has to seek. That is now a capability the backend advertises, StorageSource.openSeekable: the all-files backend answers with a real FileChannel, the SAF fallback with null, so folder mode reports needs-a-seekable-file instead of failing obscurely. Entry names go through the same ArchivePath.safeRelativePath zip-slip guard. 7z creation stays deferred.
This commit is contained in:
@@ -3,10 +3,16 @@ package de.softwareapp_hb.filemanager.core
|
||||
/**
|
||||
* Which archive formats the app can handle, by file name.
|
||||
*
|
||||
* The list is deliberately honest: ZIP, TAR, the compression wrappers, and single-file
|
||||
* gzip/bzip2/xz all come from `java.util.zip` and Commons Compress, both of which are
|
||||
* pure Java. RAR and 7z are recognised so the UI can say so instead of failing with a
|
||||
* confusing error — they need native code, which this project does not ship.
|
||||
* The list is deliberately honest about what the pure-Java libraries underneath can
|
||||
* actually do. ZIP, TAR, the compression wrappers and 7z all come from `java.util.zip`
|
||||
* and Commons Compress; RAR, CAB, ISO and WIM are recognised so the UI can say they are
|
||||
* not supported instead of failing with a confusing error.
|
||||
*
|
||||
* 7z is worth calling out, because it does **not** need native code — Commons Compress
|
||||
* ships a pure-Java 7z reader, `SevenZFile`. What it does need is **seekable** input:
|
||||
* unlike ZIP, a 7z header sits at the *end* of the file and the reader has to seek to it.
|
||||
* So 7z is only extractable on a backend that can supply a channel, and the engine says
|
||||
* so plainly when one cannot rather than failing obscurely.
|
||||
*/
|
||||
enum class ArchiveFormat {
|
||||
ZIP,
|
||||
@@ -15,8 +21,9 @@ enum class ArchiveFormat {
|
||||
GZIP,
|
||||
BZIP2,
|
||||
XZ,
|
||||
SEVEN_ZIP,
|
||||
|
||||
/** Recognised but not supported (needs native code). */
|
||||
/** Recognised but not supported — these really do need native code. */
|
||||
UNSUPPORTED,
|
||||
|
||||
/** Not an archive name we know. */
|
||||
@@ -29,7 +36,8 @@ object ArchiveFormats {
|
||||
val creatable: Set<ArchiveFormat> = setOf(ArchiveFormat.ZIP)
|
||||
|
||||
private val ZIP_NAMES = setOf("zip", "jar", "cbz")
|
||||
private val UNSUPPORTED_NAMES = setOf("7z", "rar", "cab", "iso", "wim")
|
||||
private val SEVEN_ZIP_NAMES = setOf("7z")
|
||||
private val UNSUPPORTED_NAMES = setOf("rar", "cab", "iso", "wim")
|
||||
|
||||
fun formatOf(fileName: String): ArchiveFormat {
|
||||
val lower = fileName.lowercase()
|
||||
@@ -43,6 +51,7 @@ object ArchiveFormats {
|
||||
val extension = FileKind.extensionOf(lower)
|
||||
when {
|
||||
extension in ZIP_NAMES -> ArchiveFormat.ZIP
|
||||
extension in SEVEN_ZIP_NAMES -> ArchiveFormat.SEVEN_ZIP
|
||||
extension in UNSUPPORTED_NAMES -> ArchiveFormat.UNSUPPORTED
|
||||
else -> ArchiveFormat.UNKNOWN
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.SeekableByteChannel
|
||||
import java.nio.file.StandardOpenOption
|
||||
|
||||
/**
|
||||
* The "All files access" backend: plain `java.io.File` over absolute paths.
|
||||
@@ -76,6 +79,17 @@ class AllFilesSource(
|
||||
FileInputStream(File(path.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* A real `FileChannel`, because this backend works on actual filesystem paths.
|
||||
*
|
||||
* This is what makes 7z extractable in all-files mode, and what the SAF fallback
|
||||
* cannot offer.
|
||||
*/
|
||||
override suspend fun openSeekable(path: StoragePath): SeekableByteChannel? = withContext(Dispatchers.IO) {
|
||||
val file = File(path.value)
|
||||
if (!file.isFile) null else FileChannel.open(file.toPath(), StandardOpenOption.READ)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
override suspend fun openWrite(path: StoragePath, append: Boolean): OutputStream =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.channels.SeekableByteChannel
|
||||
|
||||
/** A backend operation failed for a reason worth surfacing to the user. */
|
||||
class StorageException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -57,6 +58,18 @@ interface StorageSource {
|
||||
/** Open [path] for reading. The caller closes the stream. */
|
||||
suspend fun openRead(path: StoragePath): InputStream
|
||||
|
||||
/**
|
||||
* Open [path] as a random-access channel, or `null` when this backend cannot.
|
||||
*
|
||||
* Only 7z needs this. Unlike ZIP, a 7z header sits at the *end* of the file, so it
|
||||
* cannot be read from a forward-only stream — the reader has to seek. [AllFilesSource]
|
||||
* hands out a real `FileChannel`; [SafSource] returns `null`, because a document URI
|
||||
* has no seekable representation. The default here is that honest `null`, so a new
|
||||
* backend without seekable support degrades to "7z is not extractable here" rather
|
||||
* than to a corrupt read.
|
||||
*/
|
||||
suspend fun openSeekable(path: StoragePath): SeekableByteChannel? = null
|
||||
|
||||
/** Open [path] for writing, optionally appending. The caller closes the stream. */
|
||||
suspend fun openWrite(path: StoragePath, append: Boolean = false): OutputStream
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.apache.commons.compress.archivers.ArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
|
||||
@@ -16,6 +17,7 @@ import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.InputStream
|
||||
import java.nio.channels.SeekableByteChannel
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -24,8 +26,10 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* Creates and extracts archives, on top of the storage backends.
|
||||
*
|
||||
* Everything here is pure Java — `java.util.zip` for writing, Commons Compress for
|
||||
* reading — so the app ships no native code and all of this is testable on the JVM. RAR
|
||||
* and 7z are recognised but refused (see [ArchiveFormats]) rather than half-supported.
|
||||
* reading — so the app ships no native code and all of this is testable on the JVM. RAR is
|
||||
* recognised but refused (see [ArchiveFormats]) rather than half-supported. 7z *is* read,
|
||||
* and needs no native code either, but it is the one format that cannot arrive through a
|
||||
* stream: its header lives at the end of the file, so it needs a seekable channel.
|
||||
*
|
||||
* The security-critical part is that every entry name goes through
|
||||
* [ArchivePath.safeRelativePath] before it becomes a path: an archive must never be able
|
||||
@@ -105,41 +109,58 @@ class ArchiveEngine(private val source: StorageSource) {
|
||||
|
||||
try {
|
||||
source.createDirectory(destination)
|
||||
source.openRead(archive).use { raw ->
|
||||
BufferedInputStream(raw).use { input ->
|
||||
when (format) {
|
||||
ArchiveFormat.ZIP -> readArchive(ZipArchiveInputStream(input), destination, state)
|
||||
|
||||
ArchiveFormat.TAR -> readArchive(TarArchiveInputStream(input), destination, state)
|
||||
// 7z is the one format that cannot come in through a stream, so it is handled
|
||||
// before the stream is even opened. Only a backend with real filesystem paths
|
||||
// can hand out a channel; the SAF fallback cannot, and says so plainly rather
|
||||
// than failing with something obscure.
|
||||
if (format == ArchiveFormat.SEVEN_ZIP) {
|
||||
val channel = source.openSeekable(archive)
|
||||
if (channel == null) {
|
||||
state.failure(
|
||||
archive,
|
||||
"7z needs a seekable file, which this storage mode cannot provide",
|
||||
)
|
||||
} else {
|
||||
readSevenZip(channel, destination, state)
|
||||
}
|
||||
} else {
|
||||
source.openRead(archive).use { raw ->
|
||||
BufferedInputStream(raw).use { input ->
|
||||
when (format) {
|
||||
ArchiveFormat.ZIP -> readArchive(ZipArchiveInputStream(input), destination, state)
|
||||
|
||||
ArchiveFormat.TAR_GZIP -> readArchive(
|
||||
TarArchiveInputStream(GzipCompressorInputStream(input)),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
ArchiveFormat.TAR -> readArchive(TarArchiveInputStream(input), destination, state)
|
||||
|
||||
ArchiveFormat.GZIP -> readSingle(
|
||||
GzipCompressorInputStream(input),
|
||||
ArchiveFormats.innerName(archive.name, format),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
ArchiveFormat.TAR_GZIP -> readArchive(
|
||||
TarArchiveInputStream(GzipCompressorInputStream(input)),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
|
||||
ArchiveFormat.BZIP2 -> readSingle(
|
||||
BZip2CompressorInputStream(input),
|
||||
ArchiveFormats.innerName(archive.name, format),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
ArchiveFormat.GZIP -> readSingle(
|
||||
GzipCompressorInputStream(input),
|
||||
ArchiveFormats.innerName(archive.name, format),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
|
||||
ArchiveFormat.XZ -> readSingle(
|
||||
XZCompressorInputStream(input),
|
||||
ArchiveFormats.innerName(archive.name, format),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
ArchiveFormat.BZIP2 -> readSingle(
|
||||
BZip2CompressorInputStream(input),
|
||||
ArchiveFormats.innerName(archive.name, format),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
|
||||
else -> state.failure(archive, "This archive format is not supported")
|
||||
ArchiveFormat.XZ -> readSingle(
|
||||
XZCompressorInputStream(input),
|
||||
ArchiveFormats.innerName(archive.name, format),
|
||||
destination,
|
||||
state,
|
||||
)
|
||||
|
||||
else -> state.failure(archive, "This archive format is not supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +203,60 @@ class ArchiveEngine(private val source: StorageSource) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 7z container from [channel].
|
||||
*
|
||||
* 7z entries are not exposed as an [ArchiveInputStream]: the format decompresses in
|
||||
* solid blocks that can span entry boundaries, so the entry loop drives [SevenZFile]
|
||||
* directly and reads each entry's bytes from whatever the current one is.
|
||||
*
|
||||
* Every name still goes through [ArchivePath.safeRelativePath] before it becomes a
|
||||
* path, exactly as the streaming readers do — a 7z entry name is no more trustworthy
|
||||
* than a ZIP one, and this is the same zip-slip defence.
|
||||
*/
|
||||
private suspend fun readSevenZip(
|
||||
channel: SeekableByteChannel,
|
||||
destination: StoragePath,
|
||||
state: ArchiveState,
|
||||
) {
|
||||
// Both are closed deliberately. SevenZFile does close a channel it was handed, but
|
||||
// a second close on a FileChannel is a documented no-op, so relying on both means
|
||||
// the channel cannot leak whichever way that behaves.
|
||||
channel.use { seekable ->
|
||||
SevenZFile.builder().setSeekableByteChannel(seekable).get().use { seven ->
|
||||
val buffer = ByteArray(BUFFER_BYTES)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val entry = seven.nextEntry ?: break
|
||||
|
||||
val relative = ArchivePath.safeRelativePath(entry.name)
|
||||
if (relative == null) {
|
||||
// Recorded rather than thrown: one hostile or malformed entry must
|
||||
// not abandon the rest of the archive.
|
||||
state.failure(destination, "Skipped an unsafe entry: ${entry.name}")
|
||||
continue
|
||||
}
|
||||
|
||||
val target = destination.resolve(relative)
|
||||
if (entry.isDirectory) {
|
||||
source.createDirectory(target)
|
||||
} else {
|
||||
target.parent?.let { source.createDirectory(it) }
|
||||
source.createFile(target)
|
||||
source.openWrite(target).use { output ->
|
||||
while (true) {
|
||||
val read = seven.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
state.advance(target)
|
||||
}
|
||||
}
|
||||
if (!currentCoroutineContext().isActive) state.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSingle(
|
||||
decompressed: InputStream,
|
||||
name: String,
|
||||
|
||||
Reference in New Issue
Block a user