initial commit v 0.2.0
Verify source and build Android / android (push) Canceled after 0s

This commit is contained in:
2026-09-24 22:49:27 +02:00
commit db873063f8
129 changed files with 7512 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
plugins { alias(libs.plugins.kotlin.jvm) }
kotlin { jvmToolchain(17) }
dependencies {
implementation(project(":core"))
testImplementation(libs.junit)
testImplementation(kotlin("test"))
}
@@ -0,0 +1,75 @@
package dev.fileworkbench.platform
import dev.fileworkbench.core.ArchiveContainer
import dev.fileworkbench.core.ArchiveEntry
import dev.fileworkbench.core.PreviewException
import dev.fileworkbench.core.SafetyPolicy
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.CRC32
import kotlin.math.max
/** JVM/Android adapter. Metadata listing never inflates entries, and no entry is extracted to disk. */
class JvmZipArchive(file: File, private val policy: SafetyPolicy = SafetyPolicy()) : ArchiveContainer, Closeable {
private val zip = ZipFile(file)
private val indexed = linkedMapOf<String, ZipEntry>()
private var expanded = 0L
override val entries: List<ArchiveEntry>
init {
try {
val items = zip.entries()
while (items.hasMoreElements()) {
if (indexed.size >= policy.maxArchiveEntries) throw PreviewException("Archive contains too many entries.")
val entry = items.nextElement()
validatePath(entry.name)
if (indexed.put(entry.name, entry) != null) throw PreviewException("Archive contains duplicate paths.")
}
entries = indexed.values.map { ArchiveEntry(it.name, it.size, it.compressedSize, it.isDirectory) }
} catch (e: Exception) {
zip.close()
throw e
}
}
@Synchronized
override fun read(path: String): ByteArray {
val entry = indexed[path] ?: throw PreviewException("Missing document part: $path")
if (entry.isDirectory) throw PreviewException("A directory cannot be read as a document part.")
if (entry.size < 0 || entry.size > policy.maxEntryBytes) throw PreviewException("Document part exceeds the decompression limit.")
if (entry.compressedSize < 0 || entry.size > max(1L, entry.compressedSize) * policy.maxCompressionRatio)
throw PreviewException("Document part exceeds the compression-ratio limit.")
if (expanded + entry.size > policy.maxExpandedBytes) throw PreviewException("Document exceeds the total decompression limit.")
val output = ByteArrayOutputStream(minOf(entry.size, 65536L).toInt())
zip.getInputStream(entry).use { stream ->
val buffer = ByteArray(16 * 1024)
var count = 0
val checksum = CRC32()
while (true) {
if (Thread.currentThread().isInterrupted) throw PreviewException("Preview cancelled.")
val n = stream.read(buffer)
if (n == -1) break
count += n; expanded += n
if (count > policy.maxEntryBytes || expanded > policy.maxExpandedBytes || count.toLong() > max(1L, entry.compressedSize) * policy.maxCompressionRatio)
throw PreviewException("Expanded document exceeds the preview limit.")
checksum.update(buffer, 0, n)
output.write(buffer, 0, n)
}
if (count.toLong() != entry.size) throw PreviewException("Document part has an inconsistent size.")
if (entry.crc >= 0 && checksum.value != entry.crc) throw PreviewException("Document part failed its checksum.")
}
return output.toByteArray()
}
override fun close() = zip.close()
private fun validatePath(path: String) {
val pieces = path.removeSuffix("/").split('/')
if (path.isEmpty() || path.length > 1024 || path.startsWith('/') || '\\' in path || ':' in path || '\u0000' in path ||
pieces.any { it.isEmpty() || it == "." || it == ".." })
throw PreviewException("Archive contains an unsafe or ambiguous path.")
}
}
@@ -0,0 +1,32 @@
package dev.fileworkbench.platform
import dev.fileworkbench.core.*
import java.io.File
/** Keeps file I/O outside the common core; no Android dependencies, so this adapter can be JVM-tested. */
class LocalPreviewLoader(private val policy: SafetyPolicy = SafetyPolicy()) {
fun classify(file: File, displayName: String, mime: String?): FileKind {
val header = file.inputStream().use { stream ->
val data = ByteArray(16)
val n = stream.read(data)
if (n > 0) data.copyOf(n) else byteArrayOf()
}
return FileClassifier.classify(displayName, mime, header)
}
fun load(file: File, displayName: String, mime: String?): DocumentPreview {
if (file.length() > policy.maxInputBytes) throw PreviewException("File exceeds the input limit.")
val kind = classify(file, displayName, mime)
val result = when {
kind in FileClassifier.archiveKinds -> JvmZipArchive(file, policy).use { PreviewEngine(policy).fromArchive(kind, it) }
kind in setOf(FileKind.TEXT, FileKind.MARKDOWN, FileKind.CSV) -> {
if (file.length() > policy.maxTextBytes) throw PreviewException("Text exceeds the ${policy.maxTextBytes / 1024 / 1024} MiB preview limit.")
TextPreview.create(displayName, kind, file.readBytes(), policy)
}
kind == FileKind.LEGACY_OFFICE -> throw PreviewException("Legacy, macro-enabled and encrypted Office files are not supported in this MVP. Use a trusted editor to export PDF, DOCX, XLSX, PPTX or ODF.")
else -> throw PreviewException("No reading preview is available for this file type.")
}
PreviewSafety.validate(result, policy)
return result
}
}
@@ -0,0 +1,76 @@
package dev.fileworkbench.platform
import dev.fileworkbench.core.*
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.fail
import java.io.File
import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class ZipTests {
private fun zip(parts: Map<String, ByteArray>): File {
val file = Files.createTempFile("fileworkbench-test-", ".zip").toFile()
ZipOutputStream(file.outputStream()).use { output ->
parts.forEach { (path, bytes) -> output.putNextEntry(ZipEntry(path)); output.write(bytes); output.closeEntry() }
}
return file
}
private fun expectPreviewFailure(block: () -> Unit) {
try { block(); fail("Expected PreviewException") } catch (_: PreviewException) { }
}
@Test fun archiveRoundtrip() {
val file = zip(mapOf("folder/file.txt" to "hello".encodeToByteArray()))
try { JvmZipArchive(file).use { assertEquals("hello", it.read("folder/file.txt").decodeToString()); assertEquals(1, it.entries.size) } }
finally { file.delete() }
}
@Test fun traversalRejected() {
val file = zip(mapOf("../evil" to byteArrayOf(1)))
try { expectPreviewFailure { JvmZipArchive(file).use { } } } finally { file.delete() }
}
@Test fun absolutePathsRejected() {
val file = zip(mapOf("/etc/x" to byteArrayOf(1)))
try { expectPreviewFailure { JvmZipArchive(file).use { } } } finally { file.delete() }
}
@Test fun backslashPathsRejected() {
val file = zip(mapOf("..\\x" to byteArrayOf(1)))
try { expectPreviewFailure { JvmZipArchive(file).use { } } } finally { file.delete() }
}
@Test fun tooManyEntriesRejected() {
val file = zip(mapOf("a" to byteArrayOf(), "b" to byteArrayOf()))
try { expectPreviewFailure { JvmZipArchive(file, SafetyPolicy(maxArchiveEntries = 1)).use { } } } finally { file.delete() }
}
@Test fun oversizedEntryRejectedOnlyWhenRead() {
val file = zip(mapOf("a" to ByteArray(100) { it.toByte() }))
try { JvmZipArchive(file, SafetyPolicy(maxEntryBytes = 50)).use { assertEquals(1, it.entries.size); expectPreviewFailure { it.read("a") } } } finally { file.delete() }
}
@Test fun compressionBombRejected() {
val file = zip(mapOf("bomb" to ByteArray(500_000)))
try { JvmZipArchive(file).use { expectPreviewFailure { it.read("bomb") } } } finally { file.delete() }
}
@Test fun cumulativeExpansionBudgetEnforced() {
val file = zip(mapOf("a" to ByteArray(60) { it.toByte() }, "b" to ByteArray(60) { it.toByte() }))
try { JvmZipArchive(file, SafetyPolicy(maxExpandedBytes = 100)).use { it.read("a"); expectPreviewFailure { it.read("b") } } } finally { file.delete() }
}
@Test fun repeatedReadsConsumeBudget() {
val file = zip(mapOf("a" to ByteArray(60) { it.toByte() }))
try { JvmZipArchive(file, SafetyPolicy(maxExpandedBytes = 100)).use { it.read("a"); expectPreviewFailure { it.read("a") } } } finally { file.delete() }
}
@Test fun extensionlessOfficeLoadedThroughFullAdapter() {
val file = zip(mapOf("word/document.xml" to "<document><body><p><r><t>Real ZIP pipeline</t></r></p></body></document>".encodeToByteArray()))
try {
val result = LocalPreviewLoader().load(file, "download", "application/octet-stream")
assertEquals(FileKind.DOCX, result.kind); assertEquals("Real ZIP pipeline", result.sections[0].blocks[0].text)
} finally { file.delete() }
}
@Test fun textByteBudgetEnforcedBeforeRead() {
val file = Files.createTempFile("fileworkbench-text-", ".txt").toFile().apply { writeText("123456") }
try { expectPreviewFailure { LocalPreviewLoader(SafetyPolicy(maxTextBytes = 5)).load(file, "x.txt", "text/plain") } } finally { file.delete() }
}
@Test fun htmlRemainsInertSource() {
val file = Files.createTempFile("fileworkbench-html-", ".html").toFile().apply { writeText("<script>alert(1)</script>") }
try { assertEquals("<script>alert(1)</script>", LocalPreviewLoader().load(file, "x.html", "text/html").sections[0].blocks[0].text) } finally { file.delete() }
}
}