KTar is a Kotlin Multiplatform library to read and write tar files using okio. This library is derived from the jtar library.
It works with okio.Path, okio.BufferedSource and okio.BufferedSink, so it has no dependency on
java.io and runs unchanged on Android, the JVM and iOS.
- Read tar archives entry by entry (
TarInput) - Write tar archives entry by entry (
TarOutput) - Expand
.tar.gzarchives to disk, or stream their contents into memory (TarGzExpander) - ustar (POSIX) headers, including the filename prefix field for long paths
- UTF-8 entry names, truncated on character boundaries so code points are never split
- Entry names that would escape the destination folder are rejected when extracting
| Target | Notes |
|---|---|
| Android | minSdk 24, compiled for JVM 1.8 |
| JVM | compiled for JVM 17 |
| iOS | iosArm64 and iosSimulatorArm64, published as a static ktar XCFramework |
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.mjdenham:ktar:0.2.0")
}
}
}okio is exposed as an api dependency, so Path, Buffer and friends are available to you without
declaring okio separately.
The highest-level entry point. Creates the destination folder and any intermediate directories held in the archive.
import okio.Path.Companion.toPath
import org.martin.ktar.TarGzExpander
TarGzExpander().expandTarGzFile(
tarGzFile = "/downloads/mods.d.tar.gz".toPath(),
destFolder = "/data/modules".toPath(),
)An entry whose name resolves outside destFolder — for example one containing .. — is rejected
with an IOException rather than being written, so archives from an untrusted source cannot write
elsewhere on disk.
handleTarGzContent invokes your lambda once per file entry with the entry name and an
okio.Buffer holding its content. Directory entries are skipped.
TarGzExpander().handleTarGzContent("/downloads/mods.d.tar.gz".toPath()) { name, content ->
if (name.endsWith(".conf")) {
println("$name:\n${content.readUtf8()}")
}
}The buffer is only valid inside the lambda — read what you need before returning.
nextEntry advances to the next entry and returns null at the end of the archive. read returns
-1 once the current entry has been fully consumed, so the inner loop stops at the entry boundary.
Any unread bytes are skipped automatically when you advance to the next entry.
import okio.FileSystem
import okio.SYSTEM
import okio.buffer
import okio.use
import org.martin.ktar.TarEntry
import org.martin.ktar.TarInput
val source = FileSystem.SYSTEM.source("/downloads/archive.tar".toPath()).buffer()
TarInput(source).use { tarInput ->
val data = ByteArray(2048)
var entry: TarEntry? = tarInput.nextEntry
while (entry != null) {
if (entry.isDirectory) {
FileSystem.SYSTEM.createDirectories(destFolder.resolve(entry.name))
} else {
FileSystem.SYSTEM.sink(destFolder.resolve(entry.name)).buffer().use { dest ->
var count: Int
while (tarInput.read(data).also { count = it } != -1) {
dest.write(data, 0, count)
}
}
}
entry = tarInput.nextEntry
}
}Note that entry names may contain directories that do not appear as their own entries — create the parent of each file before writing it if the archive may be built that way.
Call putNextEntry then write exactly entry.size bytes before moving to the next entry. Closing
the TarOutput pads the final block and appends the EOF record, so the archive is only valid if you
close it.
import okio.use
import org.martin.ktar.TarEntry
import org.martin.ktar.TarOutput
TarOutput("/tmp/archive.tar".toPath()).use { tar ->
for (file in filesToArchive) {
tar.putNextEntry(TarEntry(file, file.name))
FileSystem.SYSTEM.source(file).buffer().use { source ->
val buffer = ByteArray(2048)
var length: Int
while (source.read(buffer).also { length = it } > 0) {
tar.write(buffer, 0, length)
}
}
}
}TarOutput throws an IOException if you write more bytes than the entry declared, or if you start
a new entry before the current one has been fully written.
TarHeader.createHeader lets you add entries programmatically:
import org.martin.ktar.TarEntry
import org.martin.ktar.TarHeader.Companion.createHeader
val header = createHeader(
entryName = "generated/notes.txt",
size = content.size.toLong(),
modTime = epochSeconds, // seconds, not millis
dir = false,
permissions = 493, // octal 0755
)
tar.putNextEntry(TarEntry(header))
tar.write(content)import org.martin.ktar.TarUtils.calculateTarSize
val expectedBytes = calculateTarSize(folderToArchive)| Type | Purpose |
|---|---|
TarInput(BufferedSource) |
Reads entries; nextEntry, read, currentOffset, isDefaultSkip |
TarOutput(BufferedSink) / TarOutput(Path) |
Writes entries; putNextEntry, write, flush, close |
TarEntry |
One archive entry; name, size, isDirectory, userId/groupId, header |
TarHeader |
The raw ustar header fields, plus createHeader |
TarGzExpander |
expandTarGzFile, handleTarGzContent |
TarUtils |
calculateTarSize |
TarConstants |
HEADER_BLOCK (512), DATA_BLOCK (512), EOF_BLOCK (1024) |
TarInput.currentOffset reports the byte offset from the start of the stream, which is useful for
recording where an entry's content begins. Setting isDefaultSkip = true makes skipping unread
entry bytes delegate to okio's skip instead of reading and discarding them.
- Permissions. okio has no file permission API, so entries created from a
Pathare written with a fixed mode of read access (PermissionUtils.defaultOkioPermissions). Pass an explicit mode toTarHeader.createHeaderif you need something else. Permissions are not restored when extracting. - Owner names. There is no multiplatform equivalent of
user.name, souserNamedefaults to empty anduserId/groupIddefault to0. Set them on the entry if you need them populated. - Long names. Names over 100 characters are split into the ustar filename prefix and name fields. Names whose UTF-8 encoding exceeds the 100-byte name field are truncated at a character boundary rather than failing. GNU and PAX long-name extensions are not implemented.
- Entry types. Only regular files and directories are handled by the
TarGzExpanderhelpers. Symlinks, hard links and device nodes are parsed into the header but not recreated. - Entry size. The ustar size field holds 11 octal digits, so entries of 8 GiB or larger cannot be represented. Writing one fails rather than silently recording a truncated size. GNU and PAX large file extensions are not implemented.
./gradlew build # compile all targets and run tests
./gradlew allTests # tests onlyTests live in ktar/src/commonTest and run against real archive fixtures in testFiles/ at the
repository root. They are compiled for every target and run on the JVM and Android host; the iOS
test run is disabled because the fixtures are not reachable from inside the simulator sandbox.
See CHANGELOG.md for release history.
GNU Lesser General Public License, version 2.1 — see LICENSE. Derived from jtar by Kamran Zafar.