package com.peachpasswords.ui.vault /** * Pure-Kotlin CSV / JSON import parsing for the Import screen (spec B2). * * No Android or Compose dependencies — everything here is deterministic and * unit-testable. The UI layer ([ImportScreen]) calls [detectProvider] then * [parse]; the parsed rows are mapped to VaultEntry drafts at the call site. * * Supported providers (detected from header / content shape): * - "Bitwarden CSV" (folder,favorite,type,name,login_uri,login_username,login_password,login_totp,notes) * - "Bitwarden JSON" ({"items":[...]}) * - "Credential Exchange JSON" (CXF / FIDO Alliance: {"accounts":[{"items":[{"credentials":[...]}]}]} — passkey import) * - "Proton Pass JSON" ({"vaults":{...}}) * - "1Password CSV" (Title,Url,Username,Password,... — 1Password v8 export) * - "Chrome CSV" (name,url,username,password) * - "Edge CSV" (same columns as Chrome — Chromium family) * - "Safari CSV" (Title,URL,Username,Password,Notes,OTPAuth) * - "CSV" (generic fallback: best-effort column matching) */ /** One parsed credential, provider-agnostic. Null/blank fields are omitted later. */ data class ParsedEntry( val title: String, val username: String? = null, val password: String? = null, val url: String? = null, val notes: String? = null, val totp: String? = null, val passkeys: List = emptyList(), ) /** A passkey credential extracted from a third-party export. */ data class ParsedPasskey( val credentialId: String, val rpId: String, val rpName: String? = null, val privateKeyJwk: String, val algorithm: Int, val userHandle: String? = null, val userName: String? = null, val userDisplayName: String? = null, val signCount: Int = 0, ) /** Result of an import attempt: the detected provider label plus parsed rows. */ sealed interface ImportParseResult { data class Success(val provider: String, val entries: List) : ImportParseResult data class Failure(val reason: Reason) : ImportParseResult enum class Reason { UNRECOGNISED, CORRUPTED, NO_ENTRIES, TOO_LARGE } } object CsvImport { /** * M-01: hard bounds on anything accepted from a provider export. A content * URI is backed by an arbitrary (possibly endless) provider stream, and the * parsed set is held in memory through preview and commit, so both the byte * length of the file and the shape of the parse are capped. */ const val MAX_IMPORT_BYTES = 50 * 1024 * 1024 const val MAX_IMPORT_ENTRIES = 10_000 const val MAX_FIELD_CHARS = 50 * 1024 /** * Read at most [max] bytes of UTF-8 from [input]; null when the stream is * longer. Bounded rather than readBytes() so an oversize file is refused * instead of exhausting the heap before anything is parsed. */ fun readBoundedUtf8(input: java.io.InputStream, max: Int = MAX_IMPORT_BYTES): String? { val out = java.io.ByteArrayOutputStream() val buf = ByteArray(64 * 1024) while (true) { val n = input.read(buf) if (n == -1) break if (out.size().toLong() + n > max) return null out.write(buf, 0, n) } return out.toString("UTF-8") } // Display labels used both for detection return values and "Detected: X export". const val BITWARDEN_CSV = "Bitwarden CSV" const val BITWARDEN_JSON = "Bitwarden JSON" const val CXF_JSON = "Credential Exchange JSON" const val PROTONPASS_JSON = "Proton Pass JSON" const val ONEPASSWORD_CSV = "1Password CSV" const val CHROME_CSV = "Chrome CSV" const val EDGE_CSV = "Edge CSV" const val SAFARI_CSV = "Safari CSV" const val GENERIC_CSV = "CSV" /** * Sniff the provider from raw file content. Returns a provider label or null * when the content is neither recognisable JSON nor a parseable CSV header. */ fun detectProvider(content: String): String? { val trimmed = content.trim() if (trimmed.isEmpty()) return null // CXF (FIDO Alliance Credential Exchange Format): an object carrying an // "accounts" array. Must be checked before the Bitwarden "items" sniff — // a CXF file nests "items" under each account and would otherwise // misclassify as Bitwarden JSON. if (trimmed.startsWith("{") && Regex("\"accounts\"\\s*:\\s*\\[").containsMatchIn(trimmed)) { return CXF_JSON } // Proton Pass JSON: an object carrying a "vaults" map. if (trimmed.startsWith("{") && Regex("\"vaults\"\\s*:\\s*\\{").containsMatchIn(trimmed)) { return PROTONPASS_JSON } // Bitwarden JSON: an object carrying an "items" array. Cheap shape check // rather than a full parse — the parser does the real decode. if (trimmed.startsWith("{") && Regex("\"items\"\\s*:\\s*\\[").containsMatchIn(trimmed)) { return BITWARDEN_JSON } // Any other top-level JSON we don't understand is not a CSV. if (trimmed.startsWith("{") || trimmed.startsWith("[")) return null val header = firstNonBlankLine(trimmed) ?: return null val cols = splitCsvLine(header).map { it.trim().lowercase() } val set = cols.toSet() return when { set.containsAll(listOf("name", "login_uri", "login_username", "login_password")) -> BITWARDEN_CSV // Chrome and Edge share the exact Chromium export header. They are // indistinguishable from content alone; default to Chrome, the UI // copy says "Chrome/Edge export" so the label is honest. cols == listOf("name", "url", "username", "password") -> CHROME_CSV set.containsAll(listOf("title", "url", "username", "password")) && (set.contains("otpauth") || set.contains("notes")) -> SAFARI_CSV set.containsAll(listOf("title", "url", "username", "password")) -> ONEPASSWORD_CSV // Generic: any header with at least a name/title and a password column. (set.contains("name") || set.contains("title")) && set.any { it.contains("password") } -> GENERIC_CSV else -> null } } /** * Parse [content] as the given [provider]. Provider should come from * [detectProvider]; passing an unknown label falls back to generic CSV. * Throws nothing — malformed content yields an empty list, which the UI * surfaces as the "No entries found" error. */ fun parse(content: String, provider: String): List = when (provider) { PROTONPASS_JSON -> parseProtonPassJson(content) BITWARDEN_JSON -> parseBitwardenJson(content) CXF_JSON -> parseCxfJson(content) BITWARDEN_CSV -> parseCsvByHeader(content, BitwardenCsvMap) CHROME_CSV, EDGE_CSV -> parseCsvByHeader(content, ChromeCsvMap) SAFARI_CSV -> parseCsvByHeader(content, SafariCsvMap) ONEPASSWORD_CSV -> parseCsvByHeader(content, OnePasswordCsvMap) else -> parseCsvByHeader(content, GenericCsvMap) } /** Convenience: detect + parse in one call, returning a sealed result. */ fun detectAndParse(content: String): ImportParseResult { try { val provider = detectProvider(content) ?: return ImportParseResult.Failure(ImportParseResult.Reason.UNRECOGNISED) val entries = parse(content, provider) return when { entries.isEmpty() -> ImportParseResult.Failure(ImportParseResult.Reason.NO_ENTRIES) // M-01: reject an absurd shape before it reaches the preview, where // every entry is rendered and then re-encoded into the vault write. entries.size > MAX_IMPORT_ENTRIES || entries.any { it.hasOversizeField() } -> ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE) else -> ImportParseResult.Success(provider, entries) } } catch (_: ImportTooLargeException) { return ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE) } } private class ImportTooLargeException : RuntimeException() private fun checkImportEntryCount(count: Int) { if (count > MAX_IMPORT_ENTRIES) throw ImportTooLargeException() } private fun ParsedEntry.hasOversizeField(): Boolean = listOf(title, username, password, url, notes, totp).any { (it?.length ?: 0) > MAX_FIELD_CHARS } // ---- CSV column maps -------------------------------------------------- /** * Maps lower-cased CSV header names to the [ParsedEntry] field they feed. * Each list is tried in order; the first present column wins. This lets one * generic row-builder serve every CSV provider. */ private class CsvColumnMap( val title: List, val username: List, val password: List, val url: List, val notes: List, val totp: List, ) private val BitwardenCsvMap = CsvColumnMap( title = listOf("name"), username = listOf("login_username"), password = listOf("login_password"), url = listOf("login_uri"), notes = listOf("notes"), totp = listOf("login_totp"), ) private val ChromeCsvMap = CsvColumnMap( title = listOf("name"), username = listOf("username"), password = listOf("password"), url = listOf("url"), notes = listOf("note", "notes"), totp = emptyList(), ) private val SafariCsvMap = CsvColumnMap( title = listOf("title"), username = listOf("username"), password = listOf("password"), url = listOf("url"), notes = listOf("notes"), totp = listOf("otpauth"), ) private val OnePasswordCsvMap = CsvColumnMap( title = listOf("title"), username = listOf("username"), password = listOf("password"), url = listOf("url", "website"), notes = listOf("notes"), totp = listOf("otpauth", "one-time password", "totp"), ) private val GenericCsvMap = CsvColumnMap( title = listOf("name", "title"), username = listOf("username", "user", "login", "email"), password = listOf("password", "login_password", "pass"), url = listOf("url", "login_uri", "website", "uri"), notes = listOf("notes", "note"), totp = listOf("totp", "login_totp", "otpauth"), ) private fun parseCsvByHeader(content: String, map: CsvColumnMap): List { val rows = parseCsv(content) if (rows.size < 2) return emptyList() val header = rows.first().map { it.trim().lowercase() } fun col(names: List): Int = names.firstNotNullOfOrNull { n -> header.indexOf(n).takeIf { it >= 0 } } ?: -1 val ti = col(map.title) val ui = col(map.username) val pi = col(map.password) val urli = col(map.url) val ni = col(map.notes) val toi = col(map.totp) fun cell(row: List, idx: Int): String? = if (idx in row.indices) row[idx].trim().ifEmpty { null } else null return rows.drop(1).mapNotNull { row -> if (row.all { it.isBlank() }) return@mapNotNull null val title = cell(row, ti) ?: cell(row, urli) ?: cell(row, ui) ?: return@mapNotNull null ParsedEntry( title = title, username = cell(row, ui), password = cell(row, pi), url = cell(row, urli), notes = cell(row, ni), totp = cell(row, toi), ) } } // ---- Proton Pass JSON ------------------------------------------------- private fun parseProtonPassJson(content: String): List { val vaults = extractObject(content, "vaults") ?: return emptyList() val entries = mutableListOf() for (vault in splitTopLevelObjects(vaults.removePrefix("{").removeSuffix("}"))) { if (jsonString(vault, "name").equals("recycle bin", ignoreCase = true)) continue val itemsArray = extractArray(vault, "items") ?: continue for (item in splitTopLevelObjects(itemsArray)) { val itemData = extractObject(item, "data") ?: item val itemType = jsonString(itemData, "type") ?: jsonString(item, "type") if (itemType != "login") continue val body = extractObject(itemData, "content") ?: "" val metadata = extractObject(itemData, "metadata") ?: "" val password = firstNonBlank(jsonString(body, "password"), jsonString(body, "itemPassword")) ?: continue entries += ParsedEntry( title = firstNonBlank( jsonString(metadata, "name"), jsonString(item, "name"), jsonString(item, "title"), jsonString(body, "itemName"), ) ?: "Untitled", username = firstNonBlank( jsonString(body, "username"), jsonString(body, "itemUsername"), jsonString(body, "email"), jsonString(body, "itemEmail"), jsonString(item, "aliasEmail"), ), password = password, url = firstNonBlank(extractFirstStringFromArray(body, "urls"), jsonString(body, "url")), notes = firstNonBlank(jsonString(metadata, "note"), jsonString(metadata, "notes")), totp = firstNonBlank(jsonString(body, "totpUri"), jsonString(body, "itemTotpUri")), ) } } return entries } // ---- Bitwarden JSON --------------------------------------------------- /** * Minimal extractor for Bitwarden's unencrypted JSON export. Avoids pulling * a JSON dependency into this pure module by walking the `items` array with a * tolerant brace-aware scanner; only login items (type 1) are imported. */ private fun parseBitwardenJson(content: String): List { val itemsArray = extractItemsArray(content) ?: return emptyList() val objects = splitTopLevelObjects(itemsArray) return objects.mapNotNull { obj -> val type = jsonNumber(obj, "type") // Bitwarden type 1 == login. Skip cards/notes/identities for now. if (type != null && type != 1) return@mapNotNull null val name = jsonString(obj, "name") ?: return@mapNotNull null val loginBlock = extractObject(obj, "login") ?: "" ParsedEntry( title = name, username = jsonString(loginBlock, "username"), password = jsonString(loginBlock, "password"), url = extractFirstUri(loginBlock), notes = jsonString(obj, "notes"), totp = jsonString(loginBlock, "totp"), passkeys = parseBitwardenFido2(loginBlock), ) } } private fun parseBitwardenFido2(loginBlock: String): List { val credsArray = extractArray(loginBlock, "fido2Credentials") ?: return emptyList() val creds = splitTopLevelObjects(credsArray) return creds.mapNotNull { cred -> val credentialId = jsonString(cred, "credentialId") ?: return@mapNotNull null val rpId = jsonString(cred, "rpId") ?: return@mapNotNull null val keyValue = jsonString(cred, "keyValue") ?: return@mapNotNull null val keyAlgorithm = jsonString(cred, "keyAlgorithm") ?: "ECDSA" val keyCurve = jsonString(cred, "keyCurve") ?: "P-256" val algorithm = when { keyAlgorithm.equals("ECDSA", ignoreCase = true) && keyCurve == "P-256" -> -7 keyAlgorithm.equals("EdDSA", ignoreCase = true) || keyCurve == "Ed25519" -> -8 else -> return@mapNotNull null } val jwk = buildBitwardenJwk(keyValue, algorithm) ParsedPasskey( credentialId = credentialId, rpId = rpId, rpName = jsonString(cred, "rpName"), privateKeyJwk = jwk, algorithm = algorithm, userHandle = jsonString(cred, "userHandle"), userName = jsonString(cred, "userName"), userDisplayName = jsonString(cred, "userDisplayName"), signCount = jsonNumber(cred, "counter") ?: 0, ) } } private fun buildBitwardenJwk(keyValue: String, algorithm: Int): String = when (algorithm) { -7 -> "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + keyValue + "\"}" -8 -> "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + keyValue + "\"}" else -> "{}" } // ---- Credential Exchange Format (CXF / FIDO Alliance) ----------------- /** * CXF — the JSON shape Google Password Manager (and other managers) export * passkeys in: an `accounts` array, each with `items`, each with * `credentials`. Only `type == "passkey"` credentials are imported. Mirrors * the browser extension's `parseCredentialExchangeJSON`: the same six fields * are required, and an unreadable private key skips just that credential * rather than failing the whole import. */ private fun parseCxfJson(content: String): List { val accounts = extractArray(content, "accounts") ?: return emptyList() val entries = mutableListOf() for (account in splitTopLevelObjects(accounts)) { val items = extractArray(account, "items") ?: continue for (item in splitTopLevelObjects(items)) { val creds = extractArray(item, "credentials") ?: continue val itemTitle = jsonString(item, "title") val passkeys = mutableListOf() var firstRpId: String? = null for (cred in splitTopLevelObjects(creds)) { if (!jsonString(cred, "type").equals("passkey", ignoreCase = true)) continue val credentialId = jsonString(cred, "credentialId") ?: continue val rpId = jsonString(cred, "rpId") ?: continue val userName = jsonString(cred, "username") ?: continue val userDisplayName = jsonString(cred, "userDisplayName") ?: continue val userHandle = jsonString(cred, "userHandle") ?: continue val key = jsonString(cred, "key") ?: continue val (jwk, algorithm) = cxfKeyToJwk(key) ?: continue if (firstRpId == null) firstRpId = rpId passkeys += ParsedPasskey( credentialId = credentialId, rpId = rpId, rpName = itemTitle ?: rpId, privateKeyJwk = jwk, algorithm = algorithm, userHandle = userHandle, userName = userName, userDisplayName = userDisplayName, signCount = 0, ) } if (passkeys.isEmpty()) continue // Passkey-only: leave login fields null so the UI does not spawn // an empty login entry (the browser creates a standalone passkey). entries += ParsedEntry( title = itemTitle ?: firstRpId ?: "Untitled", url = firstRpId?.let { "https://$it" }, passkeys = passkeys, ) } } return entries } /** * Decode a CXF `key` (base64url PKCS#8 DER) into the minimal signing JWK the * assertion engine consumes (`kty`/`crv`/`d`). Ed25519 vs EC P-256 is read * from the PKCS#8 AlgorithmIdentifier OID. Returns null for any key we cannot * parse — the caller skips that credential. */ private fun cxfKeyToJwk(keyB64url: String): Pair? = runCatching { val der = java.util.Base64.getUrlDecoder().decode(keyB64url) val r = DerReader(der) r.expect(0x30); r.readLength() // PrivateKeyInfo SEQUENCE r.expect(0x02); r.skip(r.readLength()) // version INTEGER r.expect(0x30) // AlgorithmIdentifier val algLen = r.readLength() val algEnd = r.pos + algLen // pos is past the length byte here r.expect(0x06); val oid = r.readValue(r.readLength()) r.pos = algEnd r.expect(0x04); val privKey = r.readValue(r.readLength()) // privateKey OCTET STRING when { oid.contentEquals(ED25519_OID) -> { // RFC 8410: privateKey wraps the 32-byte seed as an inner OCTET STRING. val inner = DerReader(privKey) inner.expect(0x04) val d = inner.readValue(inner.readLength()) "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + b64Url(d) + "\"}" to -8 } oid.contentEquals(EC_PUBLIC_KEY_OID) -> { // RFC 5915 ECPrivateKey: SEQUENCE { version, privateKey OCTET STRING, ... }. val inner = DerReader(privKey) inner.expect(0x30); inner.readLength() inner.expect(0x02); inner.skip(inner.readLength()) // version == 1 inner.expect(0x04) val d = inner.readValue(inner.readLength()) "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + b64Url(d) + "\"}" to -7 } else -> null } }.getOrNull() private fun b64Url(bytes: ByteArray): String = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) // PKCS#8 AlgorithmIdentifier OIDs (DER content, without the 06 prefix). private val ED25519_OID = byteArrayOf(0x2B, 0x65, 0x70) // 1.3.101.112 private val EC_PUBLIC_KEY_OID = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01) // 1.2.840.10045.2.1 /** Minimal DER tag-length-value walker — just enough for PKCS#8 private keys. */ private class DerReader(private val bytes: ByteArray) { var pos = 0 fun expect(tag: Int) { if (pos >= bytes.size || (bytes[pos].toInt() and 0xFF) != tag) { throw IllegalArgumentException("unexpected DER tag at $pos") } pos++ } fun readLength(): Int { val first = bytes[pos++].toInt() and 0xFF if (first < 0x80) return first var len = 0 repeat(first and 0x7F) { len = (len shl 8) or (bytes[pos++].toInt() and 0xFF) } return len } fun readValue(len: Int): ByteArray = bytes.copyOfRange(pos, pos + len).also { pos += len } fun skip(len: Int) { pos += len } } private fun extractItemsArray(content: String): String? { return extractArray(content, "items") } private fun extractArray(content: String, keyName: String): String? { val key = Regex("\"${Regex.escape(keyName)}\"\\s*:\\s*\\[").find(content) ?: return null val start = key.range.last // index of '[' var depth = 0 var i = start var inStr = false var esc = false while (i < content.length) { val c = content[i] if (inStr) { when { esc -> esc = false c == '\\' -> esc = true c == '"' -> inStr = false } } else when (c) { '"' -> inStr = true '[' -> depth++ ']' -> { depth--; if (depth == 0) return content.substring(start + 1, i) } } i++ } return null } /** Split a JSON array body into its top-level `{...}` object substrings. */ private fun splitTopLevelObjects(arrayBody: String): List { val out = mutableListOf() var depth = 0 var startIdx = -1 var inStr = false var esc = false for (i in arrayBody.indices) { val c = arrayBody[i] if (inStr) { when { esc -> esc = false c == '\\' -> esc = true c == '"' -> inStr = false } continue } when (c) { '"' -> inStr = true '{' -> { if (depth == 0) startIdx = i; depth++ } '}' -> { depth-- if (depth == 0 && startIdx >= 0) { out.add(arrayBody.substring(startIdx, i + 1)) checkImportEntryCount(out.size) startIdx = -1 } } } } return out } /** Extract the substring of a nested object value for [key], braces balanced. */ private fun extractObject(obj: String, key: String): String? { val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\\{").find(obj) ?: return null var depth = 0 var inStr = false var esc = false var i = m.range.last // index of '{' val start = i while (i < obj.length) { val c = obj[i] if (inStr) { when { esc -> esc = false c == '\\' -> esc = true c == '"' -> inStr = false } } else when (c) { '"' -> inStr = true '{' -> depth++ '}' -> { depth--; if (depth == 0) return obj.substring(start, i + 1) } } i++ } return null } private fun extractFirstUri(loginBlock: String): String? { // login.uris is [{ "match": null, "uri": "https://..." }, ...] val uriInArray = Regex("\"uris\"\\s*:\\s*\\[(.*?)]", RegexOption.DOT_MATCHES_ALL) .find(loginBlock)?.groupValues?.get(1) if (uriInArray != null) { jsonString(uriInArray, "uri")?.let { return it } } return jsonString(loginBlock, "uri") } private fun extractFirstStringFromArray(scope: String, key: String): String? { val body = extractArray(scope, key) ?: return null return Regex("\"((?:[^\"\\\\]|\\\\.)*)\"").find(body) ?.groupValues ?.get(1) ?.let(::unescapeJson) ?.ifEmpty { null } } private fun jsonString(scope: String, key: String): String? { val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(scope) ?: return null return unescapeJson(m.groupValues[1]).ifEmpty { null } } private fun jsonNumber(scope: String, key: String): Int? { val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(scope) ?: return null return m.groupValues[1].toIntOrNull() } private fun firstNonBlank(vararg values: String?): String? = values.firstOrNull { !it.isNullOrBlank() }?.trim() private fun unescapeJson(s: String): String { val sb = StringBuilder(s.length) var i = 0 while (i < s.length) { val c = s[i] if (c == '\\' && i + 1 < s.length) { when (val n = s[i + 1]) { 'n' -> sb.append('\n') 't' -> sb.append('\t') 'r' -> sb.append('\r') '"' -> sb.append('"') '\\' -> sb.append('\\') '/' -> sb.append('/') 'u' -> { if (i + 5 < s.length) { val code = s.substring(i + 2, i + 6).toIntOrNull(16) if (code != null) { sb.append(code.toChar()); i += 6; continue } } sb.append(n) } else -> sb.append(n) } i += 2 } else { sb.append(c) i++ } } return sb.toString() } // ---- RFC-4180-ish CSV -------------------------------------------------- /** * Parse a whole CSV document into rows of fields. Handles quoted fields, * embedded commas, doubled quotes ("" -> "), and newlines inside quotes * (CRLF or LF). Trailing blank line is ignored. */ fun parseCsv(content: String): List> { val rows = mutableListOf>() var field = StringBuilder() var row = mutableListOf() var inQuotes = false var i = 0 val n = content.length fun endField() { row.add(field.toString()); field = StringBuilder() } fun endRow() { endField() checkImportEntryCount(rows.size + 1) rows.add(row) row = mutableListOf() } while (i < n) { val c = content[i] if (inQuotes) { when { c == '"' && i + 1 < n && content[i + 1] == '"' -> { field.append('"'); i++ } c == '"' -> inQuotes = false else -> field.append(c) } } else { when (c) { '"' -> inQuotes = true ',' -> endField() '\r' -> { /* swallow; \n handles row end */ } '\n' -> endRow() else -> field.append(c) } } i++ } // Flush trailing field/row if the file does not end with a newline, or if // any content accumulated after the last newline. if (field.isNotEmpty() || row.isNotEmpty()) endRow() // Drop a final fully-empty row produced by a trailing newline. return rows.filterNot { it.size == 1 && it[0].isEmpty() } } /** Split a single CSV line into fields (used only for header sniffing). */ fun splitCsvLine(line: String): List = parseCsv(line).firstOrNull() ?: emptyList() private fun firstNonBlankLine(s: String): String? = s.lineSequence().firstOrNull { it.isNotBlank() } }