This commit is contained in:
@@ -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><&"'😀</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>�</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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user