
Read and write tar archives via okio streams, offering streaming IO, standard tar header handling, long-name and extended header support, and jtar-derived compatibility.
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.
TarInput)TarOutput).tar.gz archives to disk, or stream their contents into memory (TarGzExpander)| 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.1.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(),
)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.
Path are written with
a fixed mode of read access (PermissionUtils.defaultOkioPermissions). Pass an explicit mode to
TarHeader.createHeader if you need something else. Permissions are not restored when extracting.user.name, so userName defaults to
empty and userId/groupId default to 0. Set them on the entry if you need them populated.TarGzExpander helpers.
Symlinks, hard links and device nodes are parsed into the header but not recreated.... Validate that each entry resolves inside your destination directory
before extracting archives you did not create../gradlew build # compile all targets and run tests
./gradlew allTests # tests onlyTests live in ktar/src/androidHostTest and run on the JVM against real archive fixtures in
ktar/src/androidHostTest/resources.
See CHANGELOG.md for release history.
GNU Lesser General Public License, version 2.1 — see LICENSE. Derived from jtar by Kamran Zafar.
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.
TarInput)TarOutput).tar.gz archives to disk, or stream their contents into memory (TarGzExpander)| 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.1.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(),
)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.
Path are written with
a fixed mode of read access (PermissionUtils.defaultOkioPermissions). Pass an explicit mode to
TarHeader.createHeader if you need something else. Permissions are not restored when extracting.user.name, so userName defaults to
empty and userId/groupId default to 0. Set them on the entry if you need them populated.TarGzExpander helpers.
Symlinks, hard links and device nodes are parsed into the header but not recreated.... Validate that each entry resolves inside your destination directory
before extracting archives you did not create../gradlew build # compile all targets and run tests
./gradlew allTests # tests onlyTests live in ktar/src/androidHostTest and run on the JVM against real archive fixtures in
ktar/src/androidHostTest/resources.
See CHANGELOG.md for release history.
GNU Lesser General Public License, version 2.1 — see LICENSE. Derived from jtar by Kamran Zafar.