Skip to content

immat0x1/SimpliFiles

Repository files navigation

SimpliFiles logo

SimpliFiles

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

CI Snapshot Java Kotlin License

Installation

Snapshot builds are available from Maven Central Snapshots:

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

dependencies {
    implementation("io.github.immat0x1:simplifiles:0.1.5-SNAPSHOT")
}

Latest stable coordinate:

implementation("io.github.immat0x1:simplifiles:0.1.3")

Requirements

  • Runtime: Java 8+ API surface
  • Build: Java 17 toolchain
  • Kotlin/JVM
  • Archive module currently supports ZIP

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

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

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
  • 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

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

Errors

Core exception types:

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

Limitations

  • Archive module supports ZIP only
  • symlink and hardlink handling is not complete yet
  • benchmark coverage is basic

License

Apache License 2.0. See LICENSE.

About

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

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors