Unpack .tar.bz2 and .tar.xz, which only half-extracted

formatOf matched .tar.gz/.tgz as a combined format and then fell through to the bare .bz2 and .xz wrappers, so a .tar.bz2 was read as one compressed file: extracting it decompressed a single layer and left a .tar to extract again, while the same archive named .tar.gz unpacked in one step. The store listing advertised "extract ZIP, TAR, TAR.GZ, GZ, BZ2, XZ and 7z", which reads as though they all behave alike.

Found by extracting one archive of every supported format through the UI on the API 35 AVD rather than trusting the suite. A real z4.tar.bz2 left z4/z4.tar - raw ustar headers and all - beside z3.tar.gz fully unpacked to z3/a.txt and z3/sub/b.txt.

TAR_BZIP2 and TAR_XZ now sit alongside TAR_GZIP, detection consumes the whole tar suffix (.tar.bz2/.tbz2, .tar.xz/.txz) before the bare compressor, and the engine gains the two readArchive branches. The bare spellings still mean a single compressed file.

The suite had a gap that let this hide: only ZIP, GZIP, XZ and 7z had round-trip tests, so neither plain TAR nor TAR_GZIP did, and there was nothing for the asymmetry to contradict. All four tar spellings have one now, built in memory with the same library that reads them. 212 JVM tests, lint clean.

Also records the format sweep in EDGE_CASE_TESTING and re-measures the release artefacts (aab 4 838 235 / sha256 e1d23130..., apk 2 155 455 / sha256 37fb46cb...). The APK byte count happened to land on the same figure as the previous build, so the new literals were confirmed present in the shipped dex rather than trusted to the size.
This commit is contained in:
2026-10-15 12:14:39 +02:00
parent 9c39f15aa8
commit cd0fdff31b
8 changed files with 183 additions and 17 deletions
@@ -18,6 +18,8 @@ enum class ArchiveFormat {
ZIP,
TAR,
TAR_GZIP,
TAR_BZIP2,
TAR_XZ,
GZIP,
BZIP2,
XZ,
@@ -42,7 +44,14 @@ object ArchiveFormats {
fun formatOf(fileName: String): ArchiveFormat {
val lower = fileName.lowercase()
return when {
// The tar wrappers are matched before the bare compressors, and in the order
// `.tar.gz`, `.tar.bz2`, `.tar.xz`: a name has to lose the whole tar suffix to
// be recognised as a tar, otherwise `notes.txt.gz` would look like an archive
// of files rather than a compressed file. `.tgz`, `.tbz2` and `.txz` are the
// usual short spellings of the same three.
lower.endsWith(".tar.gz") || lower.endsWith(".tgz") -> ArchiveFormat.TAR_GZIP
lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2") -> ArchiveFormat.TAR_BZIP2
lower.endsWith(".tar.xz") || lower.endsWith(".txz") -> ArchiveFormat.TAR_XZ
lower.endsWith(".bz2") -> ArchiveFormat.BZIP2
lower.endsWith(".xz") -> ArchiveFormat.XZ
lower.endsWith(".tar") -> ArchiveFormat.TAR
@@ -67,15 +76,27 @@ object ArchiveFormats {
else -> true
}
/** `file.txt.gz` -> `file.txt`, so a single-file wrapper loses its own suffix. */
/**
* The name of the thing [fileName] yields once its own wrapper is gone.
*
* A single-file wrapper loses its suffix (`file.txt.gz` -> `file.txt`); a compressed tar
* becomes the tar inside it (`data.tar.bz2` -> `data.tar`), which is the name the
* extraction folder is derived from.
*/
fun innerName(fileName: String, format: ArchiveFormat): String = when (format) {
ArchiveFormat.TAR_GZIP -> fileName.removeSuffixIgnoreCase(".tgz").removeSuffixIgnoreCase(".tar.gz") + ".tar"
ArchiveFormat.TAR_GZIP -> tarInside(fileName, ".tgz", ".tar.gz")
ArchiveFormat.TAR_BZIP2 -> tarInside(fileName, ".tbz2", ".tar.bz2")
ArchiveFormat.TAR_XZ -> tarInside(fileName, ".txz", ".tar.xz")
ArchiveFormat.GZIP -> fileName.removeSuffixIgnoreCase(".gz")
ArchiveFormat.BZIP2 -> fileName.removeSuffixIgnoreCase(".bz2")
ArchiveFormat.XZ -> fileName.removeSuffixIgnoreCase(".xz")
else -> fileName
}
/** `data.tbz2` -> `data.tar`, dispatching on whichever of the two spellings is present. */
private fun tarInside(fileName: String, short: String, long: String): String =
fileName.removeSuffixIgnoreCase(short).removeSuffixIgnoreCase(long) + ".tar"
private fun String.removeSuffixIgnoreCase(suffix: String): String =
if (endsWith(suffix, ignoreCase = true)) dropLast(suffix.length) else this
}
@@ -138,6 +138,18 @@ class ArchiveEngine(private val source: StorageSource) {
state,
)
ArchiveFormat.TAR_BZIP2 -> readArchive(
TarArchiveInputStream(BZip2CompressorInputStream(input)),
destination,
state,
)
ArchiveFormat.TAR_XZ -> readArchive(
TarArchiveInputStream(XZCompressorInputStream(input)),
destination,
state,
)
ArchiveFormat.GZIP -> readSingle(
GzipCompressorInputStream(input),
ArchiveFormats.innerName(archive.name, format),
@@ -120,6 +120,32 @@ class ArchiveFormatsTest {
assertEquals("data.tar", ArchiveFormats.innerName("data.tgz", ArchiveFormat.TAR_GZIP))
}
/**
* A `.tar.bz2` and a `.tar.xz` used to be read as bare compressed files, so extracting one
* left the user holding a `.tar` to extract again — while the same archive as `.tar.gz`
* unpacked in one step. Driving all three through the UI on a device is what surfaced it.
*/
@Test
fun `a compressed tar is recognised as a tar, not as a bare compressed file`() {
assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormats.formatOf("backup.tar.bz2"))
assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormats.formatOf("backup.tbz2"))
assertEquals(ArchiveFormat.TAR_XZ, ArchiveFormats.formatOf("backup.tar.xz"))
assertEquals(ArchiveFormat.TAR_XZ, ArchiveFormats.formatOf("backup.txz"))
// The bare spellings still mean one compressed file, not an archive of files.
assertEquals(ArchiveFormat.BZIP2, ArchiveFormats.formatOf("notes.txt.bz2"))
assertEquals(ArchiveFormat.XZ, ArchiveFormats.formatOf("notes.txt.xz"))
assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormats.formatOf("backup.tar.gz"))
}
@Test
fun `every compressed tar names the tar inside it`() {
assertEquals("data.tar", ArchiveFormats.innerName("data.tar.bz2", ArchiveFormat.TAR_BZIP2))
assertEquals("data.tar", ArchiveFormats.innerName("data.tbz2", ArchiveFormat.TAR_BZIP2))
assertEquals("data.tar", ArchiveFormats.innerName("data.tar.xz", ArchiveFormat.TAR_XZ))
assertEquals("data.tar", ArchiveFormats.innerName("data.txz", ArchiveFormat.TAR_XZ))
}
@Test
fun `only zip can be created`() {
assertEquals(setOf(ArchiveFormat.ZIP), ArchiveFormats.creatable)
@@ -5,6 +5,9 @@ import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
import kotlinx.coroutines.runBlocking
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry
import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel
@@ -13,6 +16,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@@ -125,6 +129,81 @@ class ArchiveEngineTest {
assertEquals("hello xz", storage.contentOf(out.child("notes.txt")))
}
/**
* A tar holding one file, wrapped by [wrap] — the identity for a plain tar.
*
* Written with the same library that reads it, like the 7z fixture below, so it cannot
* drift from the format the way a byte literal would.
*/
private fun tarOf(
name: String,
content: String,
wrap: (OutputStream) -> OutputStream,
): ByteArray = ByteArrayOutputStream().let { bytes ->
wrap(bytes).use { wrapped ->
TarArchiveOutputStream(wrapped).use { tar ->
val body = content.toByteArray()
tar.putArchiveEntry(TarArchiveEntry(name).apply { size = body.size.toLong() })
tar.write(body)
tar.closeArchiveEntry()
}
}
bytes.toByteArray()
}
@Test
fun `a plain tar round-trips its files`() = runBlocking {
val storage = storage()
val archive = StoragePath.of("/src/backup.tar")
storage.putBytes(archive, tarOf("a.txt", "plain tar") { it })
val result = ArchiveEngine(storage).extract(archive, out)
assertTrue(result.succeeded)
assertEquals("plain tar", storage.contentOf(out.child("a.txt")))
}
@Test
fun `a gzip tar round-trips its files`() = runBlocking {
val storage = storage()
val archive = StoragePath.of("/src/backup.tar.gz")
storage.putBytes(archive, tarOf("a.txt", "gzipped tar") { GzipCompressorOutputStream(it) })
val result = ArchiveEngine(storage).extract(archive, out)
assertTrue(result.succeeded)
assertEquals("gzipped tar", storage.contentOf(out.child("a.txt")))
}
/**
* `.tar.bz2` and `.tar.xz` used to be read as *bare* compressed files, so extracting one
* left a `.tar` rather than the files inside it, while `.tar.gz` unpacked in one step.
* Driving all three through the UI on a device is what surfaced the asymmetry.
*/
@Test
fun `a bzip2 tar round-trips its files`() = runBlocking {
val storage = storage()
val archive = StoragePath.of("/src/backup.tar.bz2")
storage.putBytes(archive, tarOf("a.txt", "bzipped tar") { BZip2CompressorOutputStream(it) })
val result = ArchiveEngine(storage).extract(archive, out)
assertTrue(result.succeeded)
assertEquals("bzipped tar", storage.contentOf(out.child("a.txt")))
}
@Test
fun `an xz tar round-trips its files`() = runBlocking {
val storage = storage()
val archive = StoragePath.of("/src/backup.tar.xz")
storage.putBytes(archive, tarOf("a.txt", "xz tar") { XZCompressorOutputStream(it) })
val result = ArchiveEngine(storage).extract(archive, out)
assertTrue(result.succeeded)
assertEquals("xz tar", storage.contentOf(out.child("a.txt")))
}
@Test
fun `an unsupported format is refused rather than guessed at`() = runBlocking {
val storage = storage()