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
+21
View File
@@ -0,0 +1,21 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
plugins { alias(libs.plugins.kotlin.multiplatform) }
kotlin {
jvm()
jvmToolchain(17)
// Pure common Kotlin. Android consumes the JVM variant, not an Android-dependent core.
// Apple targets are opt-in so Android/Linux development never needs an Apple toolchain.
if (providers.gradleProperty("enableIos").orNull == "true") {
val xc = XCFramework("FileworkbenchCore")
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "FileworkbenchCore"
isStatic = true
xc.add(this)
}
}
}
sourceSets {
commonTest.dependencies { implementation(kotlin("test")) }
}
}
@@ -0,0 +1,21 @@
package dev.fileworkbench.core
/** Small public boundary usable from JVM/Android or an exported Apple framework.
* Native hosts own file access, byte limits, ZIP inflation, page/image rendering and lifecycle.
*/
class CoreApi {
fun detect(displayName: String, mimeType: String?, header: ByteArray): FileKind = FileClassifier.classify(displayName, mimeType, header)
@Throws(PreviewException::class)
fun readText(displayName: String, mimeType: String?, bytes: ByteArray): DocumentPreview {
val kind = detect(displayName, mimeType, bytes.take(16).toByteArray())
if (kind !in setOf(FileKind.TEXT, FileKind.MARKDOWN, FileKind.CSV)) throw PreviewException("Not a supported text format.")
return TextPreview.create(displayName, kind, bytes).also { PreviewSafety.validate(it) }
}
@Throws(PreviewException::class)
fun readArchive(declaredKind: FileKind, container: ArchiveContainer): DocumentPreview = PreviewEngine().fromArchive(declaredKind, container)
fun find(preview: DocumentPreview, query: String): List<SearchHit> = PreviewText.search(preview, query)
fun export(preview: DocumentPreview): String = PreviewText.render(preview)
}
@@ -0,0 +1,72 @@
package dev.fileworkbench.core
object FileClassifier {
private val extensions = mapOf(
"pdf" to FileKind.PDF,
"jpg" to FileKind.IMAGE, "jpeg" to FileKind.IMAGE, "png" to FileKind.IMAGE,
"webp" to FileKind.IMAGE, "gif" to FileKind.IMAGE, "bmp" to FileKind.IMAGE,
"md" to FileKind.MARKDOWN, "markdown" to FileKind.MARKDOWN,
"csv" to FileKind.CSV, "tsv" to FileKind.CSV,
"docx" to FileKind.DOCX, "xlsx" to FileKind.XLSX, "pptx" to FileKind.PPTX,
"odt" to FileKind.ODT, "ods" to FileKind.ODS, "odp" to FileKind.ODP,
"doc" to FileKind.LEGACY_OFFICE, "xls" to FileKind.LEGACY_OFFICE, "ppt" to FileKind.LEGACY_OFFICE,
"docm" to FileKind.LEGACY_OFFICE, "xlsm" to FileKind.LEGACY_OFFICE, "pptm" to FileKind.LEGACY_OFFICE,
"rtf" to FileKind.LEGACY_OFFICE, "zip" to FileKind.ZIP,
)
private val textExtensions = setOf("txt", "log", "json", "xml", "yaml", "yml", "toml", "ini", "cfg", "conf",
"kt", "kts", "java", "py", "sh", "bash", "js", "ts", "css", "html", "htm", "svg", "sql", "c", "h", "cpp",
"rs", "go", "php", "properties", "ics", "vcf")
private val mimeKinds = mapOf(
"application/pdf" to FileKind.PDF,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" to FileKind.DOCX,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" to FileKind.XLSX,
"application/vnd.openxmlformats-officedocument.presentationml.presentation" to FileKind.PPTX,
"application/vnd.oasis.opendocument.text" to FileKind.ODT,
"application/vnd.oasis.opendocument.spreadsheet" to FileKind.ODS,
"application/vnd.oasis.opendocument.presentation" to FileKind.ODP,
"application/zip" to FileKind.ZIP, "application/x-zip-compressed" to FileKind.ZIP,
"text/csv" to FileKind.CSV, "text/tab-separated-values" to FileKind.CSV,
"text/markdown" to FileKind.MARKDOWN,
"application/json" to FileKind.TEXT, "application/xml" to FileKind.TEXT, "image/svg+xml" to FileKind.TEXT,
"image/jpeg" to FileKind.IMAGE, "image/png" to FileKind.IMAGE, "image/webp" to FileKind.IMAGE,
"image/gif" to FileKind.IMAGE, "image/bmp" to FileKind.IMAGE,
)
fun classify(name: String, mime: String?, header: ByteArray = byteArrayOf()): FileKind {
if (starts(header, 0x25, 0x50, 0x44, 0x46, 0x2d)) return FileKind.PDF
if (starts(header, 0x89, 0x50, 0x4e, 0x47) || starts(header, 0xff, 0xd8, 0xff) ||
starts(header, 0x47, 0x49, 0x46, 0x38)) return FileKind.IMAGE
if (starts(header, 0xd0, 0xcf, 0x11, 0xe0)) return FileKind.LEGACY_OFFICE
val ext = name.substringAfterLast('.', "").lowercase()
val declared = extensions[ext] ?: if (ext in textExtensions) FileKind.TEXT else null
if (declared == FileKind.LEGACY_OFFICE) return declared
// ZIP-family claims are checked against actual entries by PreviewEngine, not trusted here.
if (starts(header, 0x50, 0x4b, 0x03, 0x04) || starts(header, 0x50, 0x4b, 0x05, 0x06)) {
return declared?.takeIf { it in archiveKinds } ?: FileKind.ZIP
}
if (declared != null) return declared
val normalizedMime = mime?.substringBefore(';')?.trim()?.lowercase()
return mimeKinds[normalizedMime] ?: if (normalizedMime?.startsWith("text/") == true) FileKind.TEXT else FileKind.UNKNOWN
}
val archiveKinds = setOf(FileKind.DOCX, FileKind.XLSX, FileKind.PPTX, FileKind.ODT, FileKind.ODS, FileKind.ODP, FileKind.ZIP)
fun inspectArchive(container: ArchiveContainer, fallback: FileKind): FileKind = when {
container.entries.any { it.path.substringAfterLast('/').equals("vbaProject.bin", ignoreCase = true) } ->
throw PreviewException("Macro-enabled Office packages are not supported by this MVP.")
container.contains("word/document.xml") -> FileKind.DOCX
container.contains("xl/workbook.xml") -> FileKind.XLSX
container.contains("ppt/presentation.xml") -> FileKind.PPTX
container.contains("mimetype") -> when (TextCodec.decode(container.read("mimetype")).trim()) {
"application/vnd.oasis.opendocument.text" -> FileKind.ODT
"application/vnd.oasis.opendocument.spreadsheet" -> FileKind.ODS
"application/vnd.oasis.opendocument.presentation" -> FileKind.ODP
else -> FileKind.ZIP
}
fallback == FileKind.ZIP -> FileKind.ZIP
else -> throw PreviewException("This file does not contain the expected document package.")
}
private fun starts(bytes: ByteArray, vararg prefix: Int): Boolean =
bytes.size >= prefix.size && prefix.indices.all { bytes[it].toInt() and 255 == prefix[it] }
}
@@ -0,0 +1,108 @@
package dev.fileworkbench.core
/** Policy values are resource ceilings, not declarations that every file below them will render. */
data class SafetyPolicy(
val maxInputBytes: Long = 64L * 1024 * 1024,
val maxTextBytes: Int = 4 * 1024 * 1024,
val maxArchiveEntries: Int = 4096,
val maxEntryBytes: Int = 8 * 1024 * 1024,
val maxExpandedBytes: Long = 32L * 1024 * 1024,
val maxCompressionRatio: Int = 300,
val maxXmlDepth: Int = 96,
val maxXmlNodes: Int = 100_000,
val maxXmlAttributes: Int = 64,
val maxBlocks: Int = 20_000,
val maxBlockCharacters: Int = 32_768,
val maxTableRows: Int = 2_000,
val maxTableColumns: Int = 128,
val maxTableCells: Int = 50_000,
val maxSections: Int = 200,
val maxSearchHits: Int = 1_000,
)
class PreviewException(message: String) : Exception(message)
enum class FileKind { PDF, IMAGE, TEXT, MARKDOWN, CSV, DOCX, XLSX, PPTX, ODT, ODS, ODP, ZIP, LEGACY_OFFICE, UNKNOWN }
enum class Fidelity { TEXT, READING_PREVIEW, ARCHIVE_INDEX }
enum class BlockKind { PARAGRAPH, HEADING, CODE, TABLE_ROW, LIST_ITEM }
data class TextBlock(val text: String, val kind: BlockKind = BlockKind.PARAGRAPH, val level: Int = 0)
data class TableCell(val column: Int, val value: String, val formula: String? = null)
data class TableRow(val number: Int, val cells: List<TableCell>)
data class TableData(val rows: List<TableRow>, val columnCount: Int)
data class PreviewSection(val title: String, val blocks: List<TextBlock> = emptyList(), val table: TableData? = null)
data class ArchiveEntry(val path: String, val size: Long, val compressedSize: Long, val directory: Boolean)
data class DocumentPreview(
val kind: FileKind,
val fidelity: Fidelity,
val sections: List<PreviewSection>,
val warnings: List<String> = emptyList(),
val entries: List<ArchiveEntry> = emptyList(),
)
data class SearchHit(val sectionIndex: Int, val itemIndex: Int, val snippet: String)
/** Native ZIP adapters enforce entry and expansion limits before exposing bytes to common code. */
interface ArchiveContainer {
val entries: List<ArchiveEntry>
fun read(path: String): ByteArray
fun contains(path: String): Boolean = entries.any { it.path == path && !it.directory }
}
interface FormatHandler {
val kinds: Set<FileKind>
fun preview(kind: FileKind, archive: ArchiveContainer, policy: SafetyPolicy): DocumentPreview
}
object PreviewText {
fun render(preview: DocumentPreview): String = buildString {
preview.sections.forEachIndexed { index, section ->
if (index > 0) append("\n\n")
append(section.title).append('\n')
section.blocks.forEach { append(it.text).append('\n') }
section.table?.rows?.forEach { row ->
append(row.number).append('\t')
append(row.cells.joinToString("\t") { "${columnLabel(it.column)}: ${it.value}" })
append('\n')
}
}
if (preview.entries.isNotEmpty()) {
preview.entries.forEach { append(it.path).append('\t').append(it.size).append(" bytes\n") }
}
}
/** Literal, case-insensitive search. No regex supplied by the user is executed. */
fun search(preview: DocumentPreview, query: String, maxHits: Int = 1000): List<SearchHit> {
val needle = query.trim().take(256)
if (needle.isEmpty() || maxHits <= 0) return emptyList()
val hits = mutableListOf<SearchHit>()
for ((s, section) in preview.sections.withIndex()) {
val lines = section.table?.rows?.map { row -> row.cells.joinToString(" | ") { it.value } }
?: section.blocks.map { it.text }
for ((i, line) in lines.withIndex()) {
val pos = line.indexOf(needle, ignoreCase = true)
if (pos >= 0) {
hits += SearchHit(s, i, line.substring((pos - 40).coerceAtLeast(0), (pos + needle.length + 100).coerceAtMost(line.length)))
if (hits.size >= maxHits) return hits
}
}
}
if (preview.entries.isNotEmpty()) {
for ((i, entry) in preview.entries.withIndex()) {
if (entry.path.contains(needle, ignoreCase = true)) {
hits += SearchHit(0, i, entry.path)
if (hits.size >= maxHits) return hits
}
}
}
return hits
}
}
fun columnLabel(index: Int): String {
require(index >= 0)
var n = index + 1
var result = ""
while (n > 0) { n--; result = ('A'.code + n % 26).toChar() + result; n /= 26 }
return result
}
@@ -0,0 +1,236 @@
package dev.fileworkbench.core
private const val OFFICE_WARNING = "Reading preview, not the original layout. Images, drawings, charts, headers, footers, notes, comments and advanced formatting may be missing. Do not use this preview to verify document completeness."
object OfficePreview : FormatHandler {
override val kinds = setOf(FileKind.DOCX, FileKind.XLSX, FileKind.PPTX, FileKind.ODT, FileKind.ODS, FileKind.ODP)
override fun preview(kind: FileKind, archive: ArchiveContainer, policy: SafetyPolicy): DocumentPreview = when (kind) {
FileKind.DOCX -> docx(archive, policy)
FileKind.XLSX -> xlsx(archive, policy)
FileKind.PPTX -> pptx(archive, policy)
FileKind.ODT, FileKind.ODS, FileKind.ODP -> odf(kind, archive, policy)
else -> throw PreviewException("Unsupported Office format.")
}
private fun docx(archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview {
val root = archive.xml("word/document.xml", p)
val body = root.children("body").firstOrNull() ?: throw PreviewException("Word document has no body.")
val blocks = mutableListOf<TextBlock>()
fun add(block: TextBlock) {
if (blocks.size >= p.maxBlocks) throw PreviewException("Document exceeds the paragraph preview limit.")
blocks += block
}
fun visit(node: XmlNode) {
when (node.localName) {
"del", "instrText" -> Unit
"p" -> {
val style = node.children("pPr").firstOrNull()?.children("pStyle")?.firstOrNull()?.attr("val") ?: ""
val level = if (style.startsWith("Heading", true)) style.drop(7).toIntOrNull()?.coerceIn(1, 6) ?: 0 else if (style.equals("Title", true)) 1 else 0
val list = node.children("pPr").any { it.children("numPr").isNotEmpty() }
add(TextBlock(wordText(node), if (level > 0) BlockKind.HEADING else if (list) BlockKind.LIST_ITEM else BlockKind.PARAGRAPH, level))
}
"tbl" -> node.children("tr").forEach { row ->
add(TextBlock(row.children("tc").joinToString(" | ") { cell -> cell.descendants("p").joinToString(" / ") { wordText(it) } }, BlockKind.TABLE_ROW))
}
else -> node.children().forEach { visit(it) }
}
}
body.children().forEach { visit(it) }
return DocumentPreview(FileKind.DOCX, Fidelity.READING_PREVIEW, listOf(PreviewSection("Document", blocks)), listOf(OFFICE_WARNING, "Tracked deletions and field instructions are omitted; inserted text and cached field results may appear. Review revisions in a full editor."))
}
private fun wordText(node: XmlNode): String = buildString {
fun walk(n: XmlNode) {
when (n.localName) {
"t" -> append(n.text())
"tab" -> append('\t')
"br", "cr" -> append('\n')
"del", "instrText", "pPr", "rPr" -> Unit
else -> n.children().forEach { walk(it) }
}
}
walk(node)
}
private fun xlsx(archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview {
val workbook = archive.xml("xl/workbook.xml", p)
val rels = relationships(archive, "xl/workbook.xml", p)
val strings = if (archive.contains("xl/sharedStrings.xml")) archive.xml("xl/sharedStrings.xml", p)
.children("si").map { it.descendants("t").joinToString("") { t -> t.text() } } else emptyList()
val sheetNodes = workbook.descendants("sheet")
if (sheetNodes.size > p.maxSections) throw PreviewException("Too many sheets for this preview.")
var cellCount = 0
val sections = sheetNodes.map { sheet ->
val name = sheet.attr("name") ?: "Sheet"
val part = rels[sheet.attr("id")] ?: throw PreviewException("Missing worksheet relationship.")
val xml = archive.xml(part, p)
val rowNodes = xml.descendants("sheetData").flatMap { it.children("row") }
if (rowNodes.size > p.maxTableRows) throw PreviewException("Sheet exceeds the row preview limit.")
var lastRow = 0
var columnCount = 0
val rows = rowNodes.map { row ->
val rowNumber = row.attr("r")?.toIntOrNull() ?: (lastRow + 1)
if (rowNumber <= lastRow || rowNumber > 1_048_576) throw PreviewException("Invalid worksheet row order.")
lastRow = rowNumber
var lastColumn = -1
val cells = row.children("c").map { cell ->
if (++cellCount > p.maxTableCells) throw PreviewException("Workbook exceeds the cell preview limit.")
val column = cell.attr("r")?.let { referenceColumn(it) } ?: lastColumn + 1
if (column <= lastColumn || column >= p.maxTableColumns) throw PreviewException("Sheet is too wide or contains invalid cell ordering.")
lastColumn = column
columnCount = maxOf(columnCount, column + 1)
val raw = cell.children("v").firstOrNull()?.text() ?: ""
val formula = cell.children("f").firstOrNull()?.text()
val value = when (cell.attr("t")) {
"s" -> strings.getOrNull(raw.toIntOrNull() ?: -1) ?: throw PreviewException("Invalid shared string reference.")
"inlineStr" -> cell.children("is").flatMap { it.descendants("t") }.joinToString("") { it.text() }
"b" -> if (raw == "1") "TRUE" else if (raw == "0") "FALSE" else raw
else -> raw.ifEmpty { if (formula != null) "[no cached value]" else "" }
}
TableCell(column, value, formula)
}
TableRow(rowNumber, cells)
}
PreviewSection(name + if (sheet.attr("state") in setOf("hidden", "veryHidden")) " (hidden)" else "", table = TableData(rows, columnCount))
}
return DocumentPreview(FileKind.XLSX, Fidelity.READING_PREVIEW, sections, listOf(OFFICE_WARNING,
"Values are cached, not recalculated. Dates may appear as serial numbers; number formats, merged cells, filters and hidden rows/columns are not reproduced. Hidden sheets are labelled and shown."))
}
private fun referenceColumn(reference: String): Int {
var value = 0
val letters = reference.takeWhile { it in 'A'..'Z' }
if (letters.isEmpty() || letters.length > 3 || reference.drop(letters.length).toIntOrNull() == null)
throw PreviewException("Invalid cell reference.")
letters.forEach { value = value * 26 + (it - 'A') + 1 }
return value - 1
}
private fun pptx(archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview {
val presentation = archive.xml("ppt/presentation.xml", p)
val rels = relationships(archive, "ppt/presentation.xml", p)
val ids = presentation.children("sldIdLst").flatMap { it.children("sldId") }
if (ids.size > p.maxSections) throw PreviewException("Too many slides for this preview.")
val sections = ids.mapIndexed { index, id ->
// sldId has both a numeric unqualified id and the relationship r:id.
val rid = id.attributes.entries.firstOrNull { it.key.endsWith(":id") }?.value
val part = rels[rid] ?: throw PreviewException("Missing slide relationship.")
val root = archive.xml(part, p)
val paragraphs = root.descendants("p")
if (paragraphs.size > p.maxBlocks) throw PreviewException("Too many slide paragraphs.")
val blocks = paragraphs.map { paragraph -> TextBlock(paragraph.descendants("t").joinToString("") { it.text() }) }
PreviewSection("Slide ${index + 1}", blocks)
}
return DocumentPreview(FileKind.PPTX, Fidelity.READING_PREVIEW, sections, listOf(OFFICE_WARNING, "Slide order follows the presentation relationships. Text order follows the XML, not the visual placement of shapes."))
}
private fun odf(kind: FileKind, archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview {
val root = archive.xml("content.xml", p)
val body = root.children("body").firstOrNull() ?: throw PreviewException("Missing OpenDocument body.")
fun paragraphBlocks(node: XmlNode): List<TextBlock> {
val blocks = mutableListOf<TextBlock>()
fun walk(n: XmlNode) {
if (n.localName == "p" || n.localName == "h") {
if (blocks.size >= p.maxBlocks) throw PreviewException("Too many OpenDocument paragraphs.")
blocks += TextBlock(odfText(n, p), if (n.localName == "h") BlockKind.HEADING else BlockKind.PARAGRAPH,
n.attr("outline-level")?.toIntOrNull()?.coerceIn(1, 6) ?: 1)
} else n.children().forEach { walk(it) }
}
walk(node)
return blocks
}
val sections: List<PreviewSection> = when (kind) {
FileKind.ODT -> listOf(PreviewSection("Document", paragraphBlocks(body)))
FileKind.ODP -> {
val pages = body.descendants("page")
if (pages.size > p.maxSections) throw PreviewException("Too many slides.")
pages.mapIndexed { i, page -> PreviewSection(page.attr("name") ?: "Slide ${i + 1}", paragraphBlocks(page)) }
}
FileKind.ODS -> {
val sheets = body.children("spreadsheet").flatMap { it.children("table") }
if (sheets.size > p.maxSections) throw PreviewException("Too many sheets.")
var totalCells = 0
sheets.map { sheet ->
val rows = mutableListOf<TableRow>()
var rowNumber = 1
var columns = 0
// Do not pull rows from nested tables inside a cell.
val rowNodes = mutableListOf<XmlNode>()
fun collectRows(n: XmlNode) {
when (n.localName) {
"table-row" -> rowNodes += n
"table", "table-row-group", "table-header-rows", "table-rows" -> n.children().forEach { collectRows(it) }
}
}
collectRows(sheet)
rowNodes.forEach { row ->
val repeatedRows = positiveCount(row.attr("number-rows-repeated"))
val cells = mutableListOf<TableCell>()
var column = 0
row.children().filter { it.localName == "table-cell" || it.localName == "covered-table-cell" }.forEach { cell ->
val repeat = positiveCount(cell.attr("number-columns-repeated"))
val paragraphs = cell.children("p")
val value = if (paragraphs.isNotEmpty()) paragraphs.joinToString("\n") { odfText(it, p) }
else cell.attr("string-value") ?: cell.attr("date-value") ?: cell.attr("boolean-value") ?: cell.attr("value") ?: ""
// Empty repeated padding need not allocate; its position is still counted.
if (value.isNotEmpty() || cell.attr("formula") != null) {
if (column.toLong() + repeat > p.maxTableColumns || repeat > p.maxTableCells) throw PreviewException("Sheet exceeds the column preview limit.")
repeat(repeat) { cells += TableCell(column + it, value, cell.attr("formula")) }
}
if (column.toLong() + repeat > 16384) throw PreviewException("Invalid repeated column count.")
column += repeat
}
if (cells.isNotEmpty()) {
if (rows.size.toLong() + repeatedRows > p.maxTableRows || totalCells.toLong() + cells.size.toLong() * repeatedRows > p.maxTableCells)
throw PreviewException("Sheet exceeds the repeated row/cell preview limit.")
repeat(repeatedRows) { rows += TableRow(rowNumber + it, cells.toList()) }
totalCells += cells.size * repeatedRows
columns = maxOf(columns, (cells.maxOfOrNull { it.column } ?: -1) + 1)
}
if (rowNumber.toLong() + repeatedRows > 1_048_577) throw PreviewException("Invalid repeated row count.")
rowNumber += repeatedRows
}
PreviewSection(sheet.attr("name") ?: "Sheet", table = TableData(rows, columns))
}
}
else -> throw PreviewException("Unsupported OpenDocument type.")
}
return DocumentPreview(kind, Fidelity.READING_PREVIEW, sections, listOf(OFFICE_WARNING) +
if (kind == FileKind.ODS) listOf("Stored cell values only; formulas are not executed. Empty padding is skipped. Hidden content and formatting are not reproduced reliably.") else emptyList())
}
private fun positiveCount(text: String?): Int = if (text == null) 1 else text.toIntOrNull()?.takeIf { it > 0 }
?: throw PreviewException("Invalid repeated element count.")
private fun odfText(node: XmlNode, p: SafetyPolicy): String = buildString {
fun walk(part: XmlPart) {
when (part) {
is XmlText -> append(part.value)
is XmlNode -> when (part.localName) {
"s" -> {
val count = positiveCount(part.attr("c"))
if (count > 10000 || length.toLong() + count > p.maxTextBytes) throw PreviewException("Too much repeated whitespace.")
repeat(count) { append(' ') }
}
"tab" -> append('\t')
"line-break" -> append('\n')
else -> part.parts.forEach { walk(it) }
}
}
if (length > p.maxTextBytes) throw PreviewException("Text exceeds preview limit.")
}
walk(node)
}
}
class PreviewEngine(private val policy: SafetyPolicy = SafetyPolicy(), private val handlers: List<FormatHandler> = listOf(OfficePreview)) {
fun fromArchive(declaredKind: FileKind, archive: ArchiveContainer): DocumentPreview {
val kind = FileClassifier.inspectArchive(archive, declaredKind)
if (kind == FileKind.ZIP) return DocumentPreview(kind, Fidelity.ARCHIVE_INDEX, emptyList(),
listOf("Archive index only. Entries are not extracted or executed; nested archives are not opened."), archive.entries)
val result = handlers.firstOrNull { kind in it.kinds }?.preview(kind, archive, policy)
?: throw PreviewException("No preview handler for this format.")
PreviewSafety.validate(result, policy)
return result
}
}
@@ -0,0 +1,27 @@
package dev.fileworkbench.core
/** Also bounds amplification from repeated shared strings/cell values in a small compressed package. */
object PreviewSafety {
fun validate(preview: DocumentPreview, policy: SafetyPolicy = SafetyPolicy()) {
var characters = 0L
var cells = 0
var blocks = 0
fun count(text: String) {
if (text.length > policy.maxBlockCharacters) throw PreviewException("A text block or cell is too long for this preview.")
characters += text.length
if (characters > policy.maxTextBytes) throw PreviewException("Expanded preview text exceeds the ${policy.maxTextBytes / 1024 / 1024} MiB limit.")
}
if (preview.sections.size > policy.maxSections) throw PreviewException("Too many preview sections.")
preview.sections.forEach { section ->
count(section.title)
section.blocks.forEach { if (++blocks > policy.maxBlocks) throw PreviewException("Too many text blocks."); count(it.text) }
section.table?.let { table ->
if (table.rows.size > policy.maxTableRows || table.columnCount > policy.maxTableColumns) throw PreviewException("Table exceeds preview limits.")
table.rows.forEach { row -> row.cells.forEach {
if (++cells > policy.maxTableCells) throw PreviewException("Too many preview cells.")
count(it.value); it.formula?.let { formula -> count(formula) }
} }
}
}
}
}
@@ -0,0 +1,197 @@
package dev.fileworkbench.core
internal sealed interface XmlPart
internal data class XmlText(val value: String) : XmlPart
internal class XmlNode(val name: String, val attributes: Map<String, String>, val parts: MutableList<XmlPart> = mutableListOf()) : XmlPart {
val localName: String get() = name.substringAfter(':')
fun attr(local: String): String? = attributes.entries.firstOrNull { it.key.substringAfter(':') == local }?.value
fun children(local: String? = null): List<XmlNode> = parts.filterIsInstance<XmlNode>().filter { local == null || it.localName == local }
fun descendants(local: String): List<XmlNode> = buildList {
fun visit(node: XmlNode) {
if (node.localName == local) add(node)
node.children().forEach { visit(it) }
}
visit(this@XmlNode)
}
fun text(): String = buildString {
fun visit(part: XmlPart) {
when (part) {
is XmlText -> append(part.value)
is XmlNode -> part.parts.forEach { visit(it) }
}
}
visit(this@XmlNode)
}
}
/** Bounded XML subset for reading previews, NOT a validating general-purpose XML library.
* No DTD, no general/external entities, no XInclude, no network, no schema processing.
* It preserves mixed-content order and verifies matched tags and quoted attributes.
*/
internal object SafeXml {
fun parse(input: String, policy: SafetyPolicy): XmlNode {
if (input.length > policy.maxEntryBytes) throw PreviewException("XML exceeds the preview limit.")
val source = input.removePrefix("\uFEFF")
var pos = 0
var nodes = 0
val stack = mutableListOf<XmlNode>()
var root: XmlNode? = null
fun count() { if (++nodes > policy.maxXmlNodes) throw PreviewException("Too many XML nodes.") }
fun attach(part: XmlPart) {
count()
if (stack.isEmpty()) {
if (part is XmlText) {
if (part.value.isNotBlank()) throw PreviewException("Text outside XML root.")
} else if (part is XmlNode) {
if (root != null) throw PreviewException("Multiple XML roots.")
root = part
}
} else stack.last().parts += part
}
fun skipWhitespace() { while (pos < source.length && source[pos].isWhitespace()) pos++ }
fun readName(): String {
val start = pos
if (pos >= source.length || !(source[pos].isLetter() || source[pos] == '_' || source[pos] == ':'))
throw PreviewException("Invalid XML name.")
pos++
while (pos < source.length && (source[pos].isLetterOrDigit() || source[pos] in "_.:-")) pos++
if (pos - start > 256) throw PreviewException("XML name too long.")
return source.substring(start, pos)
}
while (pos < source.length) {
if (source[pos] != '<') {
val end = source.indexOf('<', pos).let { if (it < 0) source.length else it }
attach(XmlText(decodeEntities(source.substring(pos, end))))
pos = end
continue
}
when {
source.startsWith("<!--", pos) -> {
val end = source.indexOf("-->", pos + 4)
if (end < 0) throw PreviewException("Unterminated XML comment.")
pos = end + 3
}
source.startsWith("<![CDATA[", pos) -> {
val end = source.indexOf("]]>", pos + 9)
if (end < 0 || stack.isEmpty()) throw PreviewException("Invalid XML CDATA.")
attach(XmlText(source.substring(pos + 9, end)))
pos = end + 3
}
source.startsWith("<!", pos) -> throw PreviewException("DTD and XML declarations other than comments/CDATA are disabled.")
source.startsWith("<?", pos) -> {
val end = source.indexOf("?>", pos + 2)
if (end < 0) throw PreviewException("Unterminated XML instruction.")
pos = end + 2 // Never executed.
}
source.startsWith("</", pos) -> {
pos += 2
val name = readName()
skipWhitespace()
if (pos >= source.length || source[pos] != '>') throw PreviewException("Invalid XML closing tag.")
pos++
if (stack.isEmpty() || stack.last().name != name) throw PreviewException("Mismatched XML tags.")
stack.removeAt(stack.lastIndex)
}
else -> {
pos++
val name = readName()
val attrs = linkedMapOf<String, String>()
var selfClosing = false
while (true) {
val beforeWhitespace = pos
skipWhitespace()
if (pos >= source.length) throw PreviewException("Unterminated XML tag.")
if (source[pos] == '>') { pos++; break }
if (source[pos] == '/' && source.getOrNull(pos + 1) == '>') { pos += 2; selfClosing = true; break }
if (pos == beforeWhitespace) throw PreviewException("Missing space before XML attribute.")
if (attrs.size >= policy.maxXmlAttributes) throw PreviewException("Too many XML attributes.")
val key = readName()
skipWhitespace()
if (source.getOrNull(pos) != '=') throw PreviewException("Invalid XML attribute.")
pos++; skipWhitespace()
val quote = source.getOrNull(pos)
if (quote != '\'' && quote != '"') throw PreviewException("Unquoted XML attribute.")
pos++
val end = source.indexOf(quote, pos)
if (end < 0) throw PreviewException("Unterminated XML attribute.")
val raw = source.substring(pos, end)
if ('<' in raw) throw PreviewException("Invalid XML attribute value.")
if (attrs.containsKey(key)) throw PreviewException("Duplicate XML attribute.")
attrs[key] = decodeEntities(raw)
pos = end + 1
}
val element = XmlNode(name, attrs)
attach(element)
if (!selfClosing) {
if (stack.size >= policy.maxXmlDepth) throw PreviewException("XML nesting exceeds preview limit.")
stack += element
}
}
}
}
if (stack.isNotEmpty()) throw PreviewException("Unclosed XML tag.")
return root ?: throw PreviewException("Empty XML document.")
}
private fun decodeEntities(text: String): String {
if ('&' !in text) return text
return buildString {
var i = 0
while (i < text.length) {
if (text[i] != '&') { append(text[i++]); continue }
val end = text.indexOf(';', i + 1)
if (end < 0 || end - i > 16) throw PreviewException("Invalid XML entity.")
val entity = text.substring(i + 1, end)
when (entity) {
"amp" -> append('&'); "lt" -> append('<'); "gt" -> append('>')
"quot" -> append('"'); "apos" -> append('\'')
else -> {
val code = when {
entity.startsWith("#x") -> entity.drop(2).toIntOrNull(16)
entity.startsWith('#') -> entity.drop(1).toIntOrNull()
else -> null
} ?: throw PreviewException("Custom XML entities are disabled.")
if (!(code == 9 || code == 10 || code == 13 || code in 0x20..0xd7ff || code in 0xe000..0xfffd || code in 0x10000..0x10ffff))
throw PreviewException("Invalid XML character reference.")
if (code <= 0xffff) append(code.toChar()) else {
val value = code - 0x10000
append((0xd800 + (value shr 10)).toChar())
append((0xdc00 + (value and 1023)).toChar())
}
}
}
i = end + 1
}
}
}
}
internal fun ArchiveContainer.xml(path: String, policy: SafetyPolicy): XmlNode = SafeXml.parse(TextCodec.decode(read(path)), policy)
/** Resolve package-relative relationships; never access a filesystem, URI or remote target. */
internal fun resolvePart(basePart: String, target: String): String {
if (target.isBlank() || '\\' in target || ':' in target || '?' in target || '#' in target || '%' in target || '\u0000' in target)
throw PreviewException("Unsupported package relationship.")
val segments = mutableListOf<String>()
if (!target.startsWith('/')) segments += basePart.substringBeforeLast('/', "").split('/').filter { it.isNotEmpty() }
for (segment in target.split('/')) {
when (segment) {
"", "." -> Unit
".." -> if (segments.isEmpty()) throw PreviewException("Package path escapes its root.") else segments.removeAt(segments.lastIndex)
else -> segments += segment
}
}
return segments.joinToString("/")
}
internal fun relationships(archive: ArchiveContainer, part: String, policy: SafetyPolicy): Map<String, String> {
val folder = part.substringBeforeLast('/', "")
val path = (if (folder.isEmpty()) "" else "$folder/") + "_rels/" + part.substringAfterLast('/') + ".rels"
if (!archive.contains(path)) return emptyMap()
return archive.xml(path, policy).descendants("Relationship").mapNotNull {
val id = it.attr("Id") ?: return@mapNotNull null
val target = it.attr("Target") ?: return@mapNotNull null
if (it.attr("TargetMode")?.equals("External", ignoreCase = true) == true) null
else id to resolvePart(part, target)
}.toMap()
}
@@ -0,0 +1,37 @@
package dev.fileworkbench.core
object TextCodec {
/** UTF-8 and BOM-tagged UTF-16 only. Never silently interpret an arbitrary binary file as text. */
fun decode(bytes: ByteArray): String {
val a = bytes.getOrNull(0)?.toInt()?.and(255)
val b = bytes.getOrNull(1)?.toInt()?.and(255)
if ((a == 255 && b == 254) || (a == 254 && b == 255)) {
if ((bytes.size - 2) % 2 != 0) throw PreviewException("Truncated UTF-16 text.")
val little = a == 255
return buildString {
var i = 2
while (i < bytes.size) {
val lo = bytes[i].toInt() and 255
val hi = bytes[i + 1].toInt() and 255
append((if (little) lo or (hi shl 8) else (lo shl 8) or hi).toChar())
i += 2
}
}.also { validateSurrogates(it) }
}
val text = try { bytes.decodeToString(throwOnInvalidSequence = true).removePrefix("\uFEFF") }
catch (_: Exception) { throw PreviewException("Unsupported text encoding. Save as UTF-8 or BOM-tagged UTF-16.") }
if ('\u0000' in text) throw PreviewException("Binary data is not supported by the text viewer.")
return text
}
private fun validateSurrogates(text: String) {
var i = 0
while (i < text.length) {
val c = text[i]
if (c.isHighSurrogate()) {
if (i + 1 >= text.length || !text[i + 1].isLowSurrogate()) throw PreviewException("Invalid UTF-16 surrogate.")
i++
} else if (c.isLowSurrogate()) throw PreviewException("Invalid UTF-16 surrogate.")
i++
}
}
}
@@ -0,0 +1,96 @@
package dev.fileworkbench.core
object TextPreview {
fun create(name: String, kind: FileKind, bytes: ByteArray, policy: SafetyPolicy = SafetyPolicy()): DocumentPreview {
if (bytes.size > policy.maxTextBytes) throw PreviewException("Text exceeds the ${policy.maxTextBytes / 1024 / 1024} MiB preview limit.")
val text = TextCodec.decode(bytes).replace("\r\n", "\n").replace('\r', '\n')
if (kind == FileKind.CSV) {
val delimiter = if (name.endsWith(".tsv", ignoreCase = true)) '\t' else CsvPreview.detectDelimiter(text)
return DocumentPreview(kind, Fidelity.TEXT, listOf(PreviewSection(name, table = CsvPreview.parse(text, delimiter, policy))),
listOf("Literal cell values; formulas are never executed. Delimiter: ${if (delimiter == '\t') "tab" else delimiter}."))
}
val lines = text.split('\n')
if (lines.size > policy.maxBlocks) throw PreviewException("Too many lines for this preview.")
var code = false
val blocks = lines.map { line ->
if (kind == FileKind.MARKDOWN) {
when {
line.startsWith("```") -> { code = !code; TextBlock(line, BlockKind.CODE) }
code -> TextBlock(line, BlockKind.CODE)
line.startsWith('#') && line.takeWhile { it == '#' }.length in 1..6 && line.getOrNull(line.takeWhile { it == '#' }.length) == ' ' -> {
val level = line.takeWhile { it == '#' }.length
TextBlock(line.drop(level + 1), BlockKind.HEADING, level)
}
line.startsWith("- ") || line.startsWith("* ") -> TextBlock(line.drop(2), BlockKind.LIST_ITEM)
else -> TextBlock(line)
}
} else TextBlock(line, BlockKind.CODE)
}
return DocumentPreview(kind, Fidelity.TEXT, listOf(PreviewSection(name, blocks)),
if (kind == FileKind.MARKDOWN) listOf("Basic Markdown reading view. Inline markup is preserved; HTML and external resources are never rendered.") else emptyList())
}
}
object CsvPreview {
/** Count delimiters only outside quotes in the first logical record. */
fun detectDelimiter(text: String): Char {
val counts = linkedMapOf(',' to 0, ';' to 0, '\t' to 0)
var quoted = false
var i = 0
while (i < text.length) {
val c = text[i]
if (c == '"') {
if (quoted && text.getOrNull(i + 1) == '"') i++ else quoted = !quoted
} else if (!quoted) {
if (c == '\n' || c == '\r') break
if (c in counts) counts[c] = counts.getValue(c) + 1
}
i++
}
return counts.maxByOrNull { it.value }!!.key
}
fun parse(text: String, delimiter: Char, policy: SafetyPolicy): TableData {
val rows = mutableListOf<TableRow>()
val cells = mutableListOf<TableCell>()
val value = StringBuilder()
var quoted = false
var closedQuote = false
var atFieldStart = true
var totalCells = 0
var maxCols = 0
fun addCell() {
if (cells.size >= policy.maxTableColumns || ++totalCells > policy.maxTableCells) throw PreviewException("CSV exceeds the cell preview limit.")
cells += TableCell(cells.size, value.toString())
value.clear(); atFieldStart = true; closedQuote = false
}
fun addRow() {
addCell()
if (rows.size >= policy.maxTableRows) throw PreviewException("CSV exceeds the row preview limit.")
rows += TableRow(rows.size + 1, cells.toList())
maxCols = maxOf(maxCols, cells.size)
cells.clear()
}
var i = 0
while (i < text.length) {
val c = text[i]
if (quoted) {
if (c == '"') {
if (text.getOrNull(i + 1) == '"') { value.append('"'); i++ }
else { quoted = false; closedQuote = true }
} else value.append(c)
} else when {
c == delimiter -> addCell()
c == '\n' || c == '\r' -> { addRow(); if (c == '\r' && text.getOrNull(i + 1) == '\n') i++ }
closedQuote -> throw PreviewException("Unexpected characters after a quoted CSV field.")
c == '"' && atFieldStart -> { quoted = true; atFieldStart = false }
c == '"' -> throw PreviewException("Unexpected quote in CSV field.")
else -> { value.append(c); atFieldStart = false }
}
i++
}
if (quoted) throw PreviewException("Unterminated quoted CSV field.")
if (value.isNotEmpty() || cells.isNotEmpty() || closedQuote || (text.isNotEmpty() && text.last() == delimiter)) addRow()
return TableData(rows, maxCols)
}
}
@@ -0,0 +1,93 @@
package dev.fileworkbench.core.browser
/** Opaque IDs belong to the platform adapter; never interpret them as filesystem paths. */
data class BrowserEntry(
val id: String,
val name: String,
val mimeType: String,
val isDirectory: Boolean = false,
val size: Long? = null,
val modifiedMillis: Long? = null,
val virtual: Boolean = false,
val thumbnailSupported: Boolean = false,
) {
val label: String get() = safeFileLabel(name)
}
data class BrowserLocation(val id: String, val label: String, val persistent: Boolean)
data class BrowserListing(
val entries: List<BrowserEntry>,
val truncated: Boolean = false,
val providerLoading: Boolean = false,
val information: String? = null,
)
enum class BrowserSort { NAME, MODIFIED, SIZE, TYPE }
data class BrowserOptions(
val query: String = "",
val sort: BrowserSort = BrowserSort.NAME,
val descending: Boolean = false,
val showHidden: Boolean = false,
)
object BrowserLimits {
const val MAX_ENTRIES = 5_000
const val MAX_LOCATIONS = 24
const val MAX_DEPTH = 64
const val MAX_SELECTED = 16
const val MAX_MIME_TYPES = 32
const val MAX_QUERY_LENGTH = 256
const val MAX_LABEL_LENGTH = 200
const val MAX_FILE_BYTES = 64L * 1024 * 1024
const val MAX_PICK_BYTES = 128L * 1024 * 1024
}
/** Prevent control/bidi characters in provider names from spoofing rows; do not alter source IDs. */
fun safeFileLabel(value: String): String = value.take(BrowserLimits.MAX_LABEL_LENGTH).map { c ->
if (c.code < 32 || c.code == 127 || c == '/' || c == '\\' ||
c in '\u202A'..'\u202E' || c in '\u2066'..'\u2069') '_' else c
}.joinToString("").trim().takeUnless { it.isEmpty() || it == "." || it == ".." } ?: "Unnamed"
/** This processes a bounded, single-directory listing; it never recursively indexes storage. */
object BrowserEntries {
fun visible(entries: List<BrowserEntry>, options: BrowserOptions, request: PickerRequest? = null): List<BrowserEntry> {
val query = options.query.trim().take(BrowserLimits.MAX_QUERY_LENGTH)
return entries.asSequence().take(BrowserLimits.MAX_ENTRIES)
.distinctBy { it.id }
.filter { options.showHidden || !it.name.startsWith('.') }
.filter { query.isEmpty() || it.name.contains(query, ignoreCase = true) }
.filter { it.isDirectory || request == null || request.accepts(it) }
.sortedWith(comparator(options)).toList()
}
fun comparator(options: BrowserOptions): Comparator<BrowserEntry> = Comparator { a, b ->
when {
a.isDirectory != b.isDirectory -> if (a.isDirectory) -1 else 1
else -> {
// Unknown metadata remains last in either direction. Folders sort by name.
val primary = when {
a.isDirectory -> compareNames(a, b) * direction(options)
options.sort == BrowserSort.MODIFIED -> compareNullable(a.modifiedMillis, b.modifiedMillis, options)
options.sort == BrowserSort.SIZE -> compareNullable(a.size, b.size, options)
options.sort == BrowserSort.TYPE -> a.mimeType.lowercase().compareTo(b.mimeType.lowercase()) * direction(options)
else -> compareNames(a, b) * direction(options)
}
if (primary != 0) primary else compareNames(a, b).takeIf { it != 0 } ?: a.id.compareTo(b.id)
}
}
}
private fun compareNames(a: BrowserEntry, b: BrowserEntry) = a.name.lowercase().compareTo(b.name.lowercase())
private fun direction(options: BrowserOptions) = if (options.descending) -1 else 1
private fun compareNullable(a: Long?, b: Long?, options: BrowserOptions): Int = when {
a == null && b == null -> 0
a == null -> 1
b == null -> -1
else -> a.compareTo(b) * direction(options)
}
}
/** Platform adapters enforce access and perform IO; the iOS adapter can use security-scoped URLs. */
interface DirectoryReader {
suspend fun list(location: BrowserLocation, directoryId: String): BrowserListing
}
@@ -0,0 +1,79 @@
package dev.fileworkbench.core.browser
/** Invalid MIME requests fail closed. EXTRA_MIME_TYPES narrows, rather than broadens, the base type. */
data class PickerRequest(
val mimeType: String = "*/*",
val extraMimeTypes: List<String> = emptyList(),
val allowMultiple: Boolean = false,
val localOnly: Boolean = false,
) {
val isValid: Boolean get() = MimeTypes.validPattern(mimeType) &&
extraMimeTypes.size <= BrowserLimits.MAX_MIME_TYPES && extraMimeTypes.all(MimeTypes::validPattern)
fun accepts(entry: BrowserEntry): Boolean = isValid && !entry.isDirectory && !entry.virtual &&
MimeTypes.matches(mimeType, entry.mimeType) &&
(extraMimeTypes.isEmpty() || extraMimeTypes.any { MimeTypes.matches(it, entry.mimeType) })
fun toggle(current: List<BrowserEntry>, entry: BrowserEntry): List<BrowserEntry> {
require(accepts(entry)) { "This file does not match the requesting app's file types." }
if (current.any { it.id == entry.id }) return current.filterNot { it.id == entry.id }
if (!allowMultiple) return listOf(entry)
require(current.size < BrowserLimits.MAX_SELECTED) { "Select at most ${BrowserLimits.MAX_SELECTED} files." }
return current + entry
}
fun validateSelection(entries: List<BrowserEntry>) {
require(entries.isNotEmpty()) { "Select a file first." }
require(entries.size <= if (allowMultiple) BrowserLimits.MAX_SELECTED else 1) { "Too many files selected." }
require(entries.distinctBy { it.id }.size == entries.size) { "Duplicate file selection." }
require(entries.all(::accepts)) { "Some selected files no longer match the requested types." }
// Declared sizes are hints. The adapter must enforce these limits on actual streamed bytes too.
var total = 0L
entries.forEach { entry ->
entry.size?.let {
require(it >= 0 && it <= BrowserLimits.MAX_FILE_BYTES) { "A selected file exceeds the 64 MiB limit." }
total += it
require(total <= BrowserLimits.MAX_PICK_BYTES) { "Selection exceeds the 128 MiB total limit." }
}
}
}
}
object MimeTypes {
fun normalize(value: String): String = value.substringBefore(';').trim().lowercase()
private fun token(value: String): Boolean = value.isNotEmpty() && value.all {
it in 'a'..'z' || it in '0'..'9' || it in "!#\$&^_.+-"
}
fun validPattern(value: String): Boolean {
if (value.length > 160) return false
val parts = normalize(value).split('/')
if (parts.size != 2) return false
return (parts[0] == "*" && parts[1] == "*") ||
(token(parts[0]) && (parts[1] == "*" || token(parts[1])))
}
fun matches(pattern: String, actual: String): Boolean {
if (!validPattern(pattern) || !validPattern(actual) || '*' in actual) return false
val p = normalize(pattern).split('/')
val a = normalize(actual).split('/')
return (p[0] == "*" || p[0] == a[0]) && (p[1] == "*" || p[1] == a[1])
}
}
/** Reused by platform copy loops. Metadata hints cannot bypass byte limits; additions cannot overflow. */
class SelectionCopyBudget(
private val perFileLimit: Long = BrowserLimits.MAX_FILE_BYTES,
private val totalLimit: Long = BrowserLimits.MAX_PICK_BYTES,
) {
init { require(perFileLimit > 0 && totalLimit > 0) }
var fileBytes: Long = 0; private set
var totalBytes: Long = 0; private set
fun beginFile() { fileBytes = 0 }
fun consume(bytes: Int) {
require(bytes >= 0) { "Invalid byte count." }
require(bytes.toLong() <= perFileLimit - fileBytes && bytes.toLong() <= totalLimit - totalBytes) {
"Selection exceeds the per-file or total byte limit."
}
fileBytes += bytes
totalBytes += bytes
}
}
@@ -0,0 +1,178 @@
package dev.fileworkbench.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFailsWith
private class MemoryArchive(parts: Map<String, String>) : ArchiveContainer {
private val data = parts.mapValues { it.value.encodeToByteArray() }
override val entries = data.map { ArchiveEntry(it.key, it.value.size.toLong(), it.value.size.toLong(), false) }
override fun read(path: String): ByteArray = data[path] ?: throw PreviewException("Missing part")
}
class CoreTests {
private val policy = SafetyPolicy()
@Test fun pdfMagicWinsOverName() { assertEquals(FileKind.PDF, FileClassifier.classify("fake.txt", "text/plain", "%PDF-1.7".encodeToByteArray())) }
@Test fun imageMagicWinsOverName() { assertEquals(FileKind.IMAGE, FileClassifier.classify("x.txt", null, byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47))) }
@Test fun caseInsensitiveExtensions() { assertEquals(FileKind.DOCX, FileClassifier.classify("HELLO.DOCX", null)) }
@Test fun mimeFallbackHandlesMissingExtension() { assertEquals(FileKind.XLSX, FileClassifier.classify("document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) }
@Test fun htmlAndSvgAreOnlyText() { assertEquals(FileKind.TEXT, FileClassifier.classify("x.svg", "image/svg+xml")); assertEquals(FileKind.TEXT, FileClassifier.classify("x.html", null)) }
@Test fun unknownBinaryIsNotAssumedText() { assertEquals(FileKind.UNKNOWN, FileClassifier.classify("random.bin", "application/octet-stream")) }
@Test fun legacyBinaryDetected() { assertEquals(FileKind.LEGACY_OFFICE, FileClassifier.classify("x.docx", null, byteArrayOf(0xd0.toByte(), 0xcf.toByte(), 0x11, 0xe0.toByte()))) }
@Test fun zipMagicDisallowsTextPretence() { assertEquals(FileKind.ZIP, FileClassifier.classify("attack.txt", "text/plain", byteArrayOf(0x50, 0x4b, 3, 4))) }
@Test fun extensionlessOfficeInspected() {
val archive = MemoryArchive(mapOf("word/document.xml" to "<document><body><p><r><t>hello</t></r></p></body></document>"))
assertEquals(FileKind.DOCX, PreviewEngine().fromArchive(FileKind.ZIP, archive).kind)
}
@Test fun utf8BomAndUnicode() { assertEquals("Grüße", TextCodec.decode(byteArrayOf(0xef.toByte(), 0xbb.toByte(), 0xbf.toByte()) + "Grüße".encodeToByteArray())) }
@Test fun utf16LittleEndian() { assertEquals("AΩ", TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0x41, 0, 0xa9.toByte(), 3))) }
@Test fun utf16BigEndian() { assertEquals("AΩ", TextCodec.decode(byteArrayOf(0xfe.toByte(), 0xff.toByte(), 0, 0x41, 3, 0xa9.toByte()))) }
@Test fun utf16RejectsOddBytes() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 1)) } }
@Test fun utf16RejectsLoneSurrogate() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0, 0xd8.toByte())) } }
@Test fun invalidUtf8Rejected() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0xc3.toByte(), 0x28)) } }
@Test fun nulBinaryRejected() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0, 1, 2)) } }
@Test fun xmlPreservesMixedContent() { assertEquals("Hello world!", SafeXml.parse("<p>Hello <span>world</span>!</p>", policy).text()) }
@Test fun xmlDecodesSafeEntities() { assertEquals("<&\"'😀", SafeXml.parse("<p>&lt;&amp;&quot;&apos;&#x1F600;</p>", policy).text()) }
@Test fun xmlCdataIsLiteral() { assertEquals("<script>&raw;", SafeXml.parse("<p><![CDATA[<script>&raw;]]></p>", policy).text()) }
@Test fun xmlRejectsDtd() { assertFailsWith<PreviewException> { SafeXml.parse("<!DOCTYPE p [<!ENTITY a SYSTEM 'file:///secret'>]><p>&a;</p>", policy) } }
@Test fun xmlRejectsCustomEntity() { assertFailsWith<PreviewException> { SafeXml.parse("<p>&xxe;</p>", policy) } }
@Test fun xmlRejectsMismatchedTags() { assertFailsWith<PreviewException> { SafeXml.parse("<a><b></a>", policy) } }
@Test fun xmlRejectsMultipleRoots() { assertFailsWith<PreviewException> { SafeXml.parse("<a/><b/>", policy) } }
@Test fun xmlRejectsDeepNesting() { assertFailsWith<PreviewException> { SafeXml.parse("<a>".repeat(100) + "</a>".repeat(100), policy) } }
@Test fun xmlRejectsTooManyNodes() { assertFailsWith<PreviewException> { SafeXml.parse("<a><b/><c/></a>", policy.copy(maxXmlNodes = 2)) } }
@Test fun xmlRejectsTooManyAttributes() { assertFailsWith<PreviewException> { SafeXml.parse("<a x='1' y='2'/>", policy.copy(maxXmlAttributes = 1)) } }
@Test fun xmlRejectsDuplicateAttribute() { assertFailsWith<PreviewException> { SafeXml.parse("<a x='1' x='2'/>", policy) } }
@Test fun xmlRejectsUnquotedAttribute() { assertFailsWith<PreviewException> { SafeXml.parse("<a x=1/>", policy) } }
@Test fun xmlRejectsInvalidCharacterReference() { assertFailsWith<PreviewException> { SafeXml.parse("<a>&#0;</a>", policy) } }
@Test fun packagePathsStayInsideRoot() { assertEquals("ppt/slides/slide1.xml", resolvePart("ppt/presentation.xml", "slides/slide1.xml")); assertEquals("xl/styles.xml", resolvePart("xl/worksheets/sheet1.xml", "../styles.xml")) }
@Test fun packageRootEscapeRejected() { assertFailsWith<PreviewException> { resolvePart("word/document.xml", "../../secret") } }
@Test fun externalRelationshipsRejected() { assertFailsWith<PreviewException> { resolvePart("word/document.xml", "https://host/file") } }
@Test fun csvQuotedDelimitersAndNewlines() {
val table = CsvPreview.parse("name,note\nAlice,\"hello,\nworld\"\n", ',', policy)
assertEquals(2, table.rows.size); assertEquals("hello,\nworld", table.rows[1].cells[1].value)
}
@Test fun csvEscapedQuotes() { assertEquals("say \"hello\"", CsvPreview.parse("\"say \"\"hello\"\"\"", ',', policy).rows[0].cells[0].value) }
@Test fun csvEmptyTrailingCells() { assertEquals(listOf("a", "", ""), CsvPreview.parse("a,,", ',', policy).rows.single().cells.map { it.value }) }
@Test fun csvWindowsNewlines() { assertEquals(2, CsvPreview.parse("a,b\r\nc,d\r\n", ',', policy).rows.size) }
@Test fun csvSemicolonDetection() { assertEquals(';', CsvPreview.detectDelimiter("name;note\nAnna;\"x,y\"")) }
@Test fun csvFormulaRemainsLiteral() { assertEquals("=SUM(A1:A3)", CsvPreview.parse("=SUM(A1:A3)", ',', policy).rows[0].cells[0].value) }
@Test fun csvBadQuotesFailClearly() { assertFailsWith<PreviewException> { CsvPreview.parse("\"hello", ',', policy) }; assertFailsWith<PreviewException> { CsvPreview.parse("\"hi\"oops", ',', policy) } }
@Test fun csvRowLimit() { assertFailsWith<PreviewException> { CsvPreview.parse("a\nb\nc", ',', policy.copy(maxTableRows = 2)) } }
@Test fun csvColumnLimit() { assertFailsWith<PreviewException> { CsvPreview.parse("a,b,c", ',', policy.copy(maxTableColumns = 2)) } }
@Test fun basicMarkdownHasHeadingsAndCode() {
val blocks = TextPreview.create("x.md", FileKind.MARKDOWN, "# Heading\n- item\n```\n# not heading\n```".encodeToByteArray()).sections[0].blocks
assertEquals(BlockKind.HEADING, blocks[0].kind); assertEquals(BlockKind.LIST_ITEM, blocks[1].kind); assertEquals(BlockKind.CODE, blocks[3].kind)
}
@Test fun textByteLimit() { assertFailsWith<PreviewException> { TextPreview.create("x.txt", FileKind.TEXT, "abcd".encodeToByteArray(), policy.copy(maxTextBytes = 3)) } }
@Test fun columnLabelsBeyondZ() { assertEquals("A", columnLabel(0)); assertEquals("Z", columnLabel(25)); assertEquals("AA", columnLabel(26)); assertEquals("DX", columnLabel(127)) }
@Test fun docxReadingTextAndTables() {
val xml = """<w:document xmlns:w="w"><w:body><w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Heading</w:t></w:r></w:p><w:p><w:r><w:t>Hello</w:t><w:tab/><w:t>world</w:t></w:r><w:del><w:r><w:t>deleted</w:t></w:r></w:del></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"""
val preview = PreviewEngine().fromArchive(FileKind.DOCX, MemoryArchive(mapOf("word/document.xml" to xml)))
assertEquals(Fidelity.READING_PREVIEW, preview.fidelity)
val blocks = preview.sections[0].blocks
assertEquals(2, blocks[0].level); assertEquals("Hello\tworld", blocks[1].text); assertEquals("A | B", blocks[2].text)
assertTrue(preview.warnings.isNotEmpty())
}
@Test fun xlsxSparseCellsAndSharedStrings() {
val preview = PreviewEngine().fromArchive(FileKind.XLSX, xlsxArchive())
val sheet = preview.sections.single()
assertEquals("Budget (hidden)", sheet.title)
val cells = sheet.table!!.rows.single().cells
assertEquals(listOf(0, 2, 3), cells.map { it.column })
assertEquals("Research", cells[0].value); assertEquals("42", cells[1].value); assertEquals("A1+C1", cells[1].formula)
assertEquals("[no cached value]", cells[2].value)
}
@Test fun xlsxTooWideRejected() {
val archive = MemoryArchive(mapOf("xl/workbook.xml" to "<workbook xmlns:r='r'><sheets><sheet name='x' r:id='a'/></sheets></workbook>",
"xl/_rels/workbook.xml.rels" to "<Relationships><Relationship Id='a' Target='worksheets/x.xml'/></Relationships>",
"xl/worksheets/x.xml" to "<worksheet><sheetData><row r='1'><c r='ZZZ1'><v>1</v></c></row></sheetData></worksheet>"))
assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.XLSX, archive) }
}
@Test fun pptxFollowsRelationshipOrderNotFilenames() {
val parts = mapOf(
"ppt/presentation.xml" to "<p:presentation xmlns:p='p' xmlns:r='r'><p:sldIdLst><p:sldId id='256' r:id='second'/><p:sldId id='257' r:id='first'/></p:sldIdLst></p:presentation>",
"ppt/_rels/presentation.xml.rels" to "<Relationships><Relationship Id='first' Target='slides/slide1.xml'/><Relationship Id='second' Target='slides/slide2.xml'/></Relationships>",
"ppt/slides/slide1.xml" to "<sld><p><r><t>First file</t></r></p></sld>",
"ppt/slides/slide2.xml" to "<sld><p><r><t>Second file</t></r></p></sld>")
val sections = PreviewEngine().fromArchive(FileKind.PPTX, MemoryArchive(parts)).sections
assertEquals("Second file", sections[0].blocks[0].text); assertEquals("First file", sections[1].blocks[0].text)
}
@Test fun odtMixedTextSpacesAndHeadings() {
val archive = odf("text", "<office:text><text:h text:outline-level='2'>Header</text:h><text:p>Hello<text:s text:c='2'/><text:span>world</text:span>!</text:p></office:text>")
val blocks = PreviewEngine().fromArchive(FileKind.ODT, archive).sections[0].blocks
assertEquals("Hello world!", blocks[1].text); assertEquals(2, blocks[0].level)
}
@Test fun odsRepeatedPaddingDoesNotExplode() {
val archive = odf("spreadsheet", "<office:spreadsheet><table:table table:name='Data'><table:table-row><table:table-cell><text:p>v</text:p></table:table-cell><table:table-cell table:number-columns-repeated='16383'/></table:table-row><table:table-row table:number-rows-repeated='1048575'><table:table-cell table:number-columns-repeated='16384'/></table:table-row></table:table></office:spreadsheet>")
val table = PreviewEngine().fromArchive(FileKind.ODS, archive).sections[0].table!!
assertEquals(1, table.rows.size); assertEquals(1, table.columnCount)
}
@Test fun odsRepeatedNonemptyRowsRejected() {
val archive = odf("spreadsheet", "<office:spreadsheet><table:table><table:table-row table:number-rows-repeated='500000'><table:table-cell><text:p>value</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet>")
assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.ODS, archive) }
}
@Test fun odpSlideNamesPreserved() {
val archive = odf("presentation", "<office:presentation><draw:page draw:name='Intro'><text:p>Hello</text:p></draw:page><draw:page draw:name='Next'><text:p>Next text</text:p></draw:page></office:presentation>")
val result = PreviewEngine().fromArchive(FileKind.ODP, archive)
assertEquals(listOf("Intro", "Next"), result.sections.map { it.title })
}
@Test fun malformedOfficePartRejected() { assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.DOCX, MemoryArchive(mapOf("word/document.xml" to "<document>"))) } }
@Test fun zipIndexDoesNotReadEntries() {
val archive = object : ArchiveContainer {
override val entries = listOf(ArchiveEntry("large.txt", 1_000_000_000L, 10, false))
override fun read(path: String): ByteArray = error("Must not inflate for index")
}
assertEquals(Fidelity.ARCHIVE_INDEX, PreviewEngine().fromArchive(FileKind.ZIP, archive).fidelity)
}
@Test fun literalSearchAndHitCap() {
val preview = TextPreview.create("x.txt", FileKind.TEXT, "Hello [world]\nhello second\nother".encodeToByteArray())
assertEquals(1, PreviewText.search(preview, "[world]").size)
assertEquals(1, PreviewText.search(preview, "HELLO", maxHits = 1).size)
assertEquals(0, PreviewText.search(preview, " ").size)
assertEquals(0, PreviewText.search(preview, "Hello", maxHits = 0).size)
}
@Test fun tableSearchUsesActualRows() { assertEquals(1, PreviewText.search(PreviewEngine().fromArchive(FileKind.XLSX, xlsxArchive()), "Research").size) }
@Test fun textExportContainsSectionNamesAndValues() {
val text = PreviewText.render(PreviewEngine().fromArchive(FileKind.XLSX, xlsxArchive()))
assertTrue("Budget (hidden)" in text); assertTrue("A: Research" in text)
}
@Test fun repeatedContentAmplificationBounded() {
val preview = DocumentPreview(FileKind.XLSX, Fidelity.READING_PREVIEW, listOf(PreviewSection("x", table = TableData(
listOf(TableRow(1, List(10) { TableCell(it, "a".repeat(200)) })), 10))))
assertFailsWith<PreviewException> { PreviewSafety.validate(preview, policy.copy(maxTextBytes = 1000)) }
}
@Test fun veryLongSingleBlockRejected() {
val preview = DocumentPreview(FileKind.TEXT, Fidelity.TEXT, listOf(PreviewSection("x", listOf(TextBlock("a".repeat(40_000))))))
assertFailsWith<PreviewException> { PreviewSafety.validate(preview) }
}
@Test fun macroExtensionsNotReclassifiedAsZip() {
assertEquals(FileKind.LEGACY_OFFICE, FileClassifier.classify("x.xlsm", null, byteArrayOf(0x50, 0x4b, 3, 4)))
}
@Test fun renamedMacroPackageRejected() {
val archive = MemoryArchive(mapOf("xl/workbook.xml" to "<workbook/>", "xl/vbaProject.bin" to "binary"))
assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.ZIP, archive) }
}
@Test fun publicCoreFacadeReadsText() {
val preview = CoreApi().readText("sample.md", null, "# Portable".encodeToByteArray())
assertEquals("Portable", preview.sections[0].blocks[0].text)
}
@Test fun publicCoreFacadeRejectsBinary() {
assertFailsWith<PreviewException> { CoreApi().readText("file.pdf", "application/pdf", "%PDF-1.4".encodeToByteArray()) }
}
private fun xlsxArchive(): ArchiveContainer = MemoryArchive(mapOf(
"xl/workbook.xml" to "<workbook xmlns:r='r'><sheets><sheet name='Budget' state='hidden' sheetId='1' r:id='rId1'/></sheets></workbook>",
"xl/_rels/workbook.xml.rels" to "<Relationships><Relationship Id='rId1' Target='worksheets/custom.xml'/><Relationship Id='external' Target='https://example.test/x' TargetMode='External'/></Relationships>",
"xl/sharedStrings.xml" to "<sst><si><r><t>Research</t></r></si></sst>",
"xl/worksheets/custom.xml" to "<worksheet><sheetData><row r='3'><c r='A3' t='s'><v>0</v></c><c r='C3'><f>A1+C1</f><v>42</v></c><c r='D3'><f>SUM(A1:A3)</f></c></row></sheetData></worksheet>"))
private fun odf(type: String, body: String): ArchiveContainer = MemoryArchive(mapOf(
"mimetype" to "application/vnd.oasis.opendocument.$type",
"content.xml" to "<office:document-content xmlns:office='office' xmlns:text='text' xmlns:table='table' xmlns:draw='draw'><office:body>$body</office:body></office:document-content>"))
}
@@ -0,0 +1,173 @@
package dev.fileworkbench.core.browser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse
import kotlin.test.assertFailsWith
class BrowserTests {
private fun file(name: String, size: Long? = null, mime: String = "text/plain", id: String = name) =
BrowserEntry(id, name, mime, size = size)
private fun folder(name: String) = BrowserEntry(name, name, "vnd.android.document/directory", isDirectory = true)
@Test fun directoriesRemainFirstWhenDescending() {
val entries = listOf(file("z.txt"), folder("A"), file("a.txt"), folder("Z"))
assertEquals(listOf("Z", "A", "z.txt", "a.txt"), BrowserEntries.visible(entries, BrowserOptions(descending = true)).map { it.name })
}
@Test fun filtersNamesCaseInsensitively() {
assertEquals(listOf("Report.PDF"), BrowserEntries.visible(listOf(file("Report.PDF"), file("other")), BrowserOptions(query = " REPORT ")).map { it.name })
}
@Test fun searchIsLiteralNotRegex() {
assertEquals(listOf("a[1].txt"), BrowserEntries.visible(listOf(file("a[1].txt"), file("a1.txt")), BrowserOptions(query = "[1]")).map { it.name })
}
@Test fun hidesDotFilesUnlessEnabled() {
val entries = listOf(file(".env"), file("normal"))
assertEquals(1, BrowserEntries.visible(entries, BrowserOptions()).size)
assertEquals(2, BrowserEntries.visible(entries, BrowserOptions(showHidden = true)).size)
}
@Test fun unknownSizesRemainLastInBothDirections() {
val entries = listOf(file("unknown"), file("big", 99), file("small", 2))
assertEquals(listOf("small", "big", "unknown"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.SIZE)).map { it.name })
assertEquals(listOf("big", "small", "unknown"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.SIZE, descending = true)).map { it.name })
}
@Test fun modifiedSortingUsesTimestamps() {
val entries = listOf(file("a").copy(modifiedMillis = 20), file("b").copy(modifiedMillis = 30), file("c"))
assertEquals(listOf("b", "a", "c"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.MODIFIED, descending = true)).map { it.name })
}
@Test fun typeSortingIsDeterministic() {
val entries = listOf(file("b", mime = "text/plain"), file("a", mime = "application/pdf"))
assertEquals(listOf("a", "b"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.TYPE)).map { it.name })
}
@Test fun duplicateProviderRowsDoNotDuplicateComposeKeys() {
assertEquals(1, BrowserEntries.visible(listOf(file("a"), file("other", id = "a")), BrowserOptions()).size)
}
@Test fun listingProcessingHasAnUpperBound() {
val entries = (0..BrowserLimits.MAX_ENTRIES).map { file("$it") }
assertEquals(BrowserLimits.MAX_ENTRIES, BrowserEntries.visible(entries, BrowserOptions()).size)
}
@Test fun labelsStripSpoofingButLeaveIdsUntouched() {
val entry = file("x\u202Etxt\n/file", id = "opaque:/id")
assertEquals("x_txt__file", entry.label)
assertEquals("opaque:/id", entry.id)
}
@Test fun labelsAreBoundedAndNonempty() {
assertEquals(BrowserLimits.MAX_LABEL_LENGTH, safeFileLabel("x".repeat(300)).length)
assertEquals("Unnamed", safeFileLabel(".."))
assertEquals("Unnamed", safeFileLabel(" "))
}
@Test fun mimeMatchingHandlesWildcardsAndParameters() {
assertTrue(MimeTypes.matches("image/*", "IMAGE/PNG"))
assertTrue(MimeTypes.matches("text/plain", "text/plain; charset=utf-8"))
assertTrue(MimeTypes.matches("*/*", "application/octet-stream"))
assertFalse(MimeTypes.matches("image/*", "application/pdf"))
}
@Test fun malformedMimeRequestsFailClosed() {
listOf("", "plain", "*/pdf", "image/p*", "x/y/z", "image /png", "*", "x/" + "a".repeat(200)).forEach {
assertFalse(PickerRequest(it).isValid, it)
assertFalse(PickerRequest(it).accepts(file("a")), it)
}
}
@Test fun wildcardIsNotAnActualFileMimeType() {
assertFalse(MimeTypes.matches("*/*", "image/*"))
}
@Test fun extraMimeTypesNarrowTheBaseType() {
val request = PickerRequest("image/*", listOf("image/png", "application/pdf"))
assertTrue(request.accepts(file("a", mime = "image/png")))
assertFalse(request.accepts(file("a", mime = "image/jpeg")))
assertFalse(request.accepts(file("a", mime = "application/pdf")))
}
@Test fun excessiveMimeTypesAreRejected() {
assertFalse(PickerRequest(extraMimeTypes = List(33) { "image/png" }).isValid)
}
@Test fun foldersStayNavigableInFilteredPicker() {
val entries = listOf(folder("A"), file("b", mime = "image/png"), file("c"))
assertEquals(listOf("A", "b"), BrowserEntries.visible(entries, BrowserOptions(), PickerRequest("image/*")).map { it.name })
}
@Test fun directoriesAndVirtualDocumentsCannotBeReturned() {
assertFalse(PickerRequest().accepts(folder("A")))
assertFalse(PickerRequest().accepts(file("cloud").copy(virtual = true)))
}
@Test fun singleSelectionReplacesPreviousSelection() {
val a = file("a"); val b = file("b")
assertEquals(listOf(b), PickerRequest().toggle(listOf(a), b))
}
@Test fun togglingSelectedFileRemovesIt() {
val a = file("a")
assertTrue(PickerRequest(allowMultiple = true).toggle(listOf(a), a).isEmpty())
}
@Test fun multipleSelectionIsBounded() {
val selected = (1..BrowserLimits.MAX_SELECTED).map { file("$it") }
assertFailsWith<IllegalArgumentException> { PickerRequest(allowMultiple = true).toggle(selected, file("extra")) }
}
@Test fun unsupportedSelectionIsRejected() {
assertFailsWith<IllegalArgumentException> { PickerRequest("image/*").toggle(emptyList(), file("a")) }
}
@Test fun emptySelectionIsRejected() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(emptyList()) }
}
@Test fun duplicateSelectionIsRejected() {
val a = file("a")
assertFailsWith<IllegalArgumentException> { PickerRequest(allowMultiple = true).validateSelection(listOf(a, a)) }
}
@Test fun singleRequestCannotReturnTwoFiles() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(listOf(file("a"), file("b"))) }
}
@Test fun individualSizeBudgetIsChecked() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(listOf(file("huge", BrowserLimits.MAX_FILE_BYTES + 1))) }
}
@Test fun aggregateSizeBudgetIsChecked() {
val entries = (1..3).map { file("$it", BrowserLimits.MAX_FILE_BYTES) }
assertFailsWith<IllegalArgumentException> { PickerRequest(allowMultiple = true).validateSelection(entries) }
}
@Test fun negativeSizeIsNotTrusted() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(listOf(file("x", -1))) }
}
@Test fun unknownSizesAreAllowedPendingStreamingLimit() {
PickerRequest().validateSelection(listOf(file("unknown")))
}
@Test fun comparatorRemainsAntisymmetric() {
val entries = listOf(folder("A"), folder("B"), file("a", 1), file("A", 4), file("a", id = "other"))
BrowserSort.values().forEach { sort ->
val comparator = BrowserEntries.comparator(BrowserOptions(sort = sort, descending = true))
for (a in entries) for (b in entries) {
assertEquals(-comparator.compare(b, a).compareTo(0), comparator.compare(a, b).compareTo(0))
}
}
}
@Test fun actualStreamBytesAreLimitedEvenWithUnknownMetadata() {
val budget = SelectionCopyBudget(perFileLimit = 10, totalLimit = 20)
budget.beginFile(); budget.consume(8)
assertFailsWith<IllegalArgumentException> { budget.consume(3) }
assertEquals(8L, budget.totalBytes)
}
@Test fun totalByteBudgetSpansFiles() {
val budget = SelectionCopyBudget(perFileLimit = 10, totalLimit = 15)
budget.beginFile(); budget.consume(10)
budget.beginFile(); budget.consume(5)
assertFailsWith<IllegalArgumentException> { budget.consume(1) }
}
@Test fun newFileResetsOnlyTheFileCounter() {
val budget = SelectionCopyBudget(10, 30)
budget.consume(9); budget.beginFile()
assertEquals(0L, budget.fileBytes)
assertEquals(9L, budget.totalBytes)
}
@Test fun zeroByteFilesAreAllowed() {
val budget = SelectionCopyBudget(10, 30)
budget.beginFile(); budget.consume(0)
assertEquals(0L, budget.totalBytes)
}
@Test fun negativeCopyCountsAreRejected() {
assertFailsWith<IllegalArgumentException> { SelectionCopyBudget().consume(-1) }
}
@Test fun byteCountersRejectHugeChunksWithoutOverflow() {
val budget = SelectionCopyBudget(8, 10)
assertFailsWith<IllegalArgumentException> { budget.consume(Int.MAX_VALUE) }
assertEquals(0L, budget.totalBytes)
}
@Test fun invalidCopyBudgetsAreRejected() {
assertFailsWith<IllegalArgumentException> { SelectionCopyBudget(0, 10) }
assertFailsWith<IllegalArgumentException> { SelectionCopyBudget(10, -1) }
}
}