Skip to content

Repository files navigation

SimpliFiles logo

SimpliFiles

Safe and convenient file toolkit for Java and Kotlin, with archive-first APIs.

CI Maven Central Java Kotlin License

Installation

dependencies {
    implementation("io.github.immat0x1:simplifiles:0.1.6")
}

Snapshot builds of in-progress work are available from Maven Central Snapshots:

repositories {
    maven {
        url = uri("https://central.sonatype.com/repository/maven-snapshots/")
        content {
            includeModule("io.github.immat0x1", "simplifiles")
        }
    }
}

Requirements

  • Runtime: Java 8+ API surface
  • Build: Java 17 toolchain
  • Kotlin/JVM
  • Archive module currently supports ZIP
  • Android: API 26+, or API 21+ with nio desugaring (see below)

Android

SimpliFiles is built on java.nio.file, which Android ships only from API 26 (Android 8.0). On older devices those classes are absent at runtime, so any call fails with NoClassDefFoundError regardless of the Java language level you compile against.

To support devices below API 26, enable core library desugaring with the nio variant. The default desugar_jdk_libs artifact does not include java.nio:

android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5")
}

This requires Android Gradle Plugin 7.4.0 or newer. Below API 26 the emulation is partial, and two gaps affect SimpliFiles:

  • StandardCopyOption.ATOMIC_MOVE degrades to a plain rename, so writeTextAtomic and writeFromAtomic give a weaker guarantee than on API 26+.
  • A missing file may surface as FileNotFoundException instead of NoSuchFileException. Catch IOException if you need to handle both.

Why SimpliFiles

Working with files and archives in Java/Kotlin is powerful, but the safe version of simple tasks often turns into repetitive infrastructure code. Extracting a ZIP means validating paths, creating directories, copying streams, handling duplicate entries, checking sizes, cleaning partial output, and only then reading the files you actually needed.

SimpliFiles was created to make those workflows short by default and safer by default.

Common pain Standard API reality SimpliFiles approach
Extracting archives takes too much boilerplate You manually iterate entries, normalize paths, create directories, copy streams, and close resources SimpliFiles.archive(path).extractTo(...) validates and extracts with a focused API
Archive extraction is easy to make unsafe Zip Slip, absolute paths, duplicate paths, oversized entries, and zip bombs are easy to miss Strict SecurityPolicy is applied before extraction and while bytes are written
You only get files on disk after extraction The caller has to rebuild convenience helpers around the output directory ExtractedArchive, ArchiveFile, and ArchiveDirectory provide handles for reading, editing, deleting, moving, and saving
Reading user-controlled files can accidentally load too much readText() and readBytes() have no built-in limit Bounded reads make limits explicit: readText(maxBytes = ...)
Copying streams into files is repetitive You open output streams, create parents, copy bytes, and remember limits yourself writeFrom(...) and writeFromAtomic(...) write streams directly into file handles
Updating small metadata files is awkward to do safely Direct writes can leave half-written files after failures writeTextAtomic(...) writes through a temporary file and swaps it into place
Repacking or creating ZIP files is verbose You manually build ZipOutputStream and preserve directory entries yourself ExtractedArchive.zipTo(...), SimpliDirectory.zipTo(...), and SimpliFiles.pack() create ZIP output from high-level handles
Java and Android integrations often still need File Code has to bounce between Path, File, and custom checks SimpliFile.file and SimpliDirectory.file expose Java File views without pushing NIO details into app code

The library is archive-first, not archive-only. The same entry point also exposes regular file and directory helpers for common filesystem workflows.

Quick Start

import org.simplifiles.SimpliFiles
import org.simplifiles.files.OverwritePolicy

val workspace = SimpliFiles.directory("workspace").create()

workspace.file("notes/today.txt")
    .writeTextAtomic("Ship small, useful APIs.\n")

workspace.file("notes/today.txt")
    .copyTo(workspace.resolveInside("backup/today.txt"), OverwritePolicy.ERROR)

val text = workspace.file("notes/today.txt")
    .readText(maxBytes = 64 * 1024)

File Recipes

Atomic Writes

Use atomic writes for files that should never be left half-written, such as JSON metadata, config files, indexes, manifests, and cache descriptors.

val file = SimpliFiles.file("config/settings.json")

file.writeTextAtomic(
    """
    {
      "theme": "system",
      "sync": true
    }
    """.trimIndent()
)

Bounded Reads

Use bounded reads when file size is controlled by a user or external input.

val manifest = SimpliFiles.file("manifest.json")
    .readText(maxBytes = 256 * 1024)

If the file is larger than the limit, SimpliFiles throws FileOperationException.

Bounded Lines

val metadata = SimpliFiles.file("META-INF/MANIFEST.MF")
    .readLines(maxBytes = 4L * 1024L * 1024L)

SimpliFiles.file("events.log")
    .forEachLine(maxBytes = 8L * 1024L * 1024L) { line ->
        println(line)
    }

Stream Writes and Markers

inputStream.use { input ->
    SimpliFiles.file("downloads/archive.zip")
        .writeFromAtomic(input, maxBytes = 128L * 1024L * 1024L)
}

SimpliFiles.file("state/.installed")
    .touch()

Copy and Move Policies

import org.simplifiles.files.OverwritePolicy

val source = SimpliFiles.file("input/report.txt")

source.copyTo("output/report.txt", OverwritePolicy.ERROR)
source.copyTo("output/latest.txt", OverwritePolicy.REPLACE)
source.moveTo("archive/report.txt", OverwritePolicy.SKIP)

Policies:

  • ERROR fails when the target already exists
  • REPLACE replaces the target
  • SKIP leaves the target unchanged

Directory Recipes

Zip a Directory

val workspace = SimpliFiles.directory("workspace").create()

workspace.file("reports/summary.txt").writeTextAtomic("Processed 42 records.\n")
workspace.file("reports/details.txt").writeText("Everything completed successfully.\n")

val archive = workspace.zipTo("workspace.zip")

With save options:

import org.simplifiles.archive.ArchiveEntryFilter
import org.simplifiles.archive.ArchiveSaveOptions
import org.simplifiles.files.OverwritePolicy

val options = ArchiveSaveOptions.builder()
    .overwritePolicy(OverwritePolicy.REPLACE)
    .compressionLevel(ArchiveSaveOptions.BEST_SPEED_LEVEL)
    .entryFilter(
        ArchiveEntryFilter.allOf(
            ArchiveEntryFilter.not(ArchiveEntryFilter.pathStartsWith("tmp/")),
            ArchiveEntryFilter.not(ArchiveEntryFilter.pathEndsWith(".log")),
        )
    )
    .build()

workspace.zipTo("workspace.zip", options)

For only target overwrite behavior, pass the policy directly:

workspace.zipTo("workspace.zip", OverwritePolicy.REPLACE)

Pack Files and Directories

Use SimpliFiles.pack() when the archive should be assembled from independent files or directories without first copying them into a staging directory.

val archive = SimpliFiles.pack()
    .addFile("README.md", "docs/README.md")
    .addDirectory("assets", "public/assets")
    .zipTo("bundle.zip")

Reproducible Archives

Entries keep the modification time of their source file. Set a fixed entryTimestamp when the same input tree must always produce the same archive bytes.

val options = ArchiveSaveOptions.builder()
    .entryTimestamp(0L)
    .build()

SimpliFiles.directory("dist").zipTo("dist.zip", options)

Safe Child Paths

resolveInside rejects absolute paths and parent traversal before returning a normalized path inside the directory root.

val root = SimpliFiles.directory("data").create()

val safePath = root.resolveInside("users/alice/profile.json")
root.file("users/alice/profile.json").writeTextAtomic("{}")

Unsafe paths throw UnsafePathException:

root.file("../outside.txt")
root.file("/etc/passwd")
root.file("C:\\Windows\\system.ini")

Walk Files

val files = SimpliFiles.directory("data")
    .walkFiles()
    .filter { it.extension == "json" }

Copy or Move Directory Trees

val source = SimpliFiles.directory("public").create()

source.copyTo("dist/public", OverwritePolicy.REPLACE)
source.moveTo("archive/public", OverwritePolicy.ERROR)

For directory-specific workflows, use DirectoryTransferOptions. DirectoryOverwritePolicy.MERGE keeps existing target files that are not touched by the source tree, while replacing conflicting source paths.

import org.simplifiles.files.DirectoryOverwritePolicy
import org.simplifiles.files.DirectoryTransferOptions

val options = DirectoryTransferOptions.builder()
    .overwritePolicy(DirectoryOverwritePolicy.MERGE)
    .maxFiles(10_000)
    .maxBytes(512L * 1024L * 1024L)
    .build()

SimpliFiles.directory("assets")
    .copyTo("build/assets", options)

Use clean() when a workspace directory should stay in place, but all of its contents should be removed.

SimpliFiles.directory("build/tmp")
    .clean()

Symbolic Links

A symbolic link can point outside the tree being processed. Following one copies the target's content into the destination as a regular file, which moves data across a trust boundary without the caller noticing. SimpliFiles therefore skips symbolic links by default when it walks a directory tree for zipTo(...), pack().addDirectory(...), copyTo(...), and moveTo(...).

import org.simplifiles.files.SymlinkPolicy

// Fail instead of skipping.
SimpliFiles.directory("assets").copyTo(
    "build/assets",
    DirectoryTransferOptions.builder()
        .symlinkPolicy(SymlinkPolicy.ERROR)
        .build(),
)

// Read through links, for trees you control.
SimpliFiles.directory("assets").zipTo(
    "assets.zip",
    ArchiveSaveOptions.builder()
        .symlinkPolicy(SymlinkPolicy.FOLLOW)
        .build(),
)

A file named directly through pack().addFile(...) is an explicit choice by the caller, so it is always archived, link or not.

Archive Recipes

Validate Before Extracting

import org.simplifiles.SimpliFiles
import org.simplifiles.archive.security.SecurityPolicy

val report = SimpliFiles.archive("upload.zip")
    .withPolicy(SecurityPolicy.strict())
    .validate()

if (report.isSafe) {
    SimpliFiles.archive("upload.zip")
        .withPolicy(SecurityPolicy.strict())
        .extractTo("output")
}

Extract to a Directory Handle

Use extractToDirectory when the caller only needs the final directory, not a mutable extracted archive session.

val output = SimpliFiles.archive("upload.zip")
    .extractToDirectory("output")

val manifest = output.file("manifest.json")
    .readText(maxBytes = 256 * 1024)

Target Policy

import org.simplifiles.archive.ArchiveExtractionOptions
import org.simplifiles.archive.ExtractionTargetPolicy

val options = ArchiveExtractionOptions.builder()
    .targetPolicy(ExtractionTargetPolicy.CLEAN)
    .build()

SimpliFiles.archive("upload.zip")
    .extractToDirectory("output", options)

Extract to a Temporary Workspace

Temporary extractions are deleted when the use block exits.

SimpliFiles.archive("bundle.zip")
    .extractToTemp()
    .use { archive ->
        val manifest = archive.file("manifest.json").readText()

        archive.file("processed.txt").writeText(manifest)
        archive.find("**/*.tmp").forEach { it.delete() }
        archive.zipTo("bundle-clean.zip")
    }

Preview an Extraction Plan

val plan = SimpliFiles.archive("backup.zip")
    .planExtractionTo("restore")

println("entries: ${plan.totalEntries}")
println("bytes: ${plan.totalBytesToWrite}")
println("safe: ${plan.isSafe}")

Progress and Cancellation

import org.simplifiles.archive.ArchiveExtractionOptions
import org.simplifiles.archive.CancellationToken

val token = CancellationToken { Thread.currentThread().isInterrupted }

val options = ArchiveExtractionOptions.builder()
    .bufferSize(128 * 1024)
    .cancellationToken(token)
    .progressListener { progress ->
        println("${progress.entriesProcessed}/${progress.totalEntries}")
    }
    .build()

SimpliFiles.archive("large.zip").extractTo("output", options)

Java Example

import org.simplifiles.SimpliFiles;
import org.simplifiles.archive.ExtractedArchive;
import org.simplifiles.archive.security.SecurityPolicy;
import org.simplifiles.files.OverwritePolicy;

String metadata = SimpliFiles.directory("workspace")
        .file("metadata.json")
        .readText(64 * 1024);

SimpliFiles.file("workspace/metadata.json")
        .copyTo("workspace/metadata.backup.json", OverwritePolicy.ERROR);

try (ExtractedArchive archive = SimpliFiles.archive("bundle.zip")
        .withPolicy(SecurityPolicy.strict())
        .extractToTemp()) {
    archive.file("summary.txt").writeText(metadata);
    archive.zipTo("bundle-updated.zip");
}

Features

  • Regular file read, write, append, copy, move, delete
  • Bounded file reads
  • Bounded line reads
  • Atomic text and byte writes
  • Stream writes and atomic stream writes
  • Empty marker creation with touch()
  • Directory create, list, walk, copy, move, recursive delete
  • Safe child path resolution inside a directory root
  • Copy and move overwrite policies
  • ZIP inspection without extraction
  • ZIP validation report
  • Dry-run extraction plans
  • Safe extraction to a new, empty, cleaned, or replaced directory
  • Direct extraction to a SimpliDirectory
  • Temporary extraction with cleanup on close
  • Extraction progress callbacks
  • Extraction cancellation tokens
  • Configurable extraction buffer size
  • Save progress callbacks, cancellation tokens, and buffer size
  • Save overwrite policy, compression level, and entry filters
  • Symbolic links skipped by default when walking directory trees, with SymlinkPolicy
  • Source modification times preserved in ZIP entries, with a fixed-timestamp option for reproducible output
  • Value equality and readable toString on file, directory, and archive handles
  • Direct ZIP overwrite policy shortcuts
  • ZIP creation from independent files and directories with SimpliFiles.pack()
  • JMH benchmarks against direct Java ZIP baselines
  • Glob search for extracted files
  • Save modified extracted contents back to ZIP

Security Defaults

SecurityPolicy.strict() is the default archive policy.

It rejects or limits:

  • path traversal
  • absolute paths
  • Windows absolute paths
  • duplicate paths
  • file/directory path conflicts
  • unsupported compression methods
  • suspicious compression ratio
  • maximum entry count
  • maximum single file size
  • maximum total uncompressed size

Limits are checked during validation and again while bytes are written, so an archive that lies about its declared sizes is still stopped mid-extraction.

Regular directory handles also reject child paths that escape their root.

SecurityPolicy.maxNestedArchiveDepth, allowSymlinks, and allowHardlinks are reserved. Their defaults describe what SimpliFiles already does — it never extracts nested archives and never recreates links — but raising them has no effect yet, because java.util.zip.ZipEntry does not expose the external attributes that mark a link. These are unrelated to SymlinkPolicy, which controls links found in a source tree while writing an archive.

Errors

Core exception types:

  • FileOperationException
  • UnsafePathException
  • ArchiveValidationException
  • UnsafeArchivePathException
  • ExtractionTargetException
  • ArchiveWriteException
  • ArchiveOperationException
  • ArchiveOperationCanceledException
  • UnsupportedArchiveFormatException
  • CorruptedArchiveException

Limitations

  • Archive module supports ZIP only
  • Symbolic links are skipped, not stored, when writing archives; extraction never recreates links
  • SecurityPolicy.allowSymlinks, allowHardlinks, and maxNestedArchiveDepth are reserved and not enforced yet
  • benchmark coverage is basic
  • Android below API 26 needs nio desugaring, with the caveats listed under Requirements

License

Apache License 2.0. See LICENSE.

About

Safe and convenient file toolkit for Java and Kotlin, with archive-first APIs.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages