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),