This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user