CsvImport.kt

30.1 KB · raw

1package com.peachpasswords.ui.vault23/**4 * Pure-Kotlin CSV / JSON import parsing for the Import screen (spec B2).5 *6 * No Android or Compose dependencies — everything here is deterministic and7 * unit-testable. The UI layer ([ImportScreen]) calls [detectProvider] then8 * [parse]; the parsed rows are mapped to VaultEntry drafts at the call site.9 *10 * Supported providers (detected from header / content shape):11 *   - "Bitwarden CSV"  (folder,favorite,type,name,login_uri,login_username,login_password,login_totp,notes)12 *   - "Bitwarden JSON" ({"items":[...]})13 *   - "Credential Exchange JSON" (CXF / FIDO Alliance: {"accounts":[{"items":[{"credentials":[...]}]}]} — passkey import)14 *   - "Proton Pass JSON" ({"vaults":{...}})15 *   - "1Password CSV"  (Title,Url,Username,Password,... — 1Password v8 export)16 *   - "Chrome CSV"     (name,url,username,password)17 *   - "Edge CSV"       (same columns as Chrome — Chromium family)18 *   - "Safari CSV"     (Title,URL,Username,Password,Notes,OTPAuth)19 *   - "CSV"            (generic fallback: best-effort column matching)20 */2122/** One parsed credential, provider-agnostic. Null/blank fields are omitted later. */23data class ParsedEntry(24    val title: String,25    val username: String? = null,26    val password: String? = null,27    val url: String? = null,28    val notes: String? = null,29    val totp: String? = null,30    val passkeys: List<ParsedPasskey> = emptyList(),31)3233/** A passkey credential extracted from a third-party export. */34data class ParsedPasskey(35    val credentialId: String,36    val rpId: String,37    val rpName: String? = null,38    val privateKeyJwk: String,39    val algorithm: Int,40    val userHandle: String? = null,41    val userName: String? = null,42    val userDisplayName: String? = null,43    val signCount: Int = 0,44)4546/** Result of an import attempt: the detected provider label plus parsed rows. */47sealed interface ImportParseResult {48    data class Success(val provider: String, val entries: List<ParsedEntry>) : ImportParseResult49    data class Failure(val reason: Reason) : ImportParseResult5051    enum class Reason { UNRECOGNISED, CORRUPTED, NO_ENTRIES, TOO_LARGE }52}5354object CsvImport {5556    /**57     * M-01: hard bounds on anything accepted from a provider export. A content58     * URI is backed by an arbitrary (possibly endless) provider stream, and the59     * parsed set is held in memory through preview and commit, so both the byte60     * length of the file and the shape of the parse are capped.61     */62    const val MAX_IMPORT_BYTES = 50 * 1024 * 102463    const val MAX_IMPORT_ENTRIES = 10_00064    const val MAX_FIELD_CHARS = 50 * 10246566    /**67     * Read at most [max] bytes of UTF-8 from [input]; null when the stream is68     * longer. Bounded rather than readBytes() so an oversize file is refused69     * instead of exhausting the heap before anything is parsed.70     */71    fun readBoundedUtf8(input: java.io.InputStream, max: Int = MAX_IMPORT_BYTES): String? {72        val out = java.io.ByteArrayOutputStream()73        val buf = ByteArray(64 * 1024)74        while (true) {75            val n = input.read(buf)76            if (n == -1) break77            if (out.size().toLong() + n > max) return null78            out.write(buf, 0, n)79        }80        return out.toString("UTF-8")81    }8283    // Display labels used both for detection return values and "Detected: X export".84    const val BITWARDEN_CSV = "Bitwarden CSV"85    const val BITWARDEN_JSON = "Bitwarden JSON"86    const val CXF_JSON = "Credential Exchange JSON"87    const val PROTONPASS_JSON = "Proton Pass JSON"88    const val ONEPASSWORD_CSV = "1Password CSV"89    const val CHROME_CSV = "Chrome CSV"90    const val EDGE_CSV = "Edge CSV"91    const val SAFARI_CSV = "Safari CSV"92    const val GENERIC_CSV = "CSV"9394    /**95     * Sniff the provider from raw file content. Returns a provider label or null96     * when the content is neither recognisable JSON nor a parseable CSV header.97     */98    fun detectProvider(content: String): String? {99        val trimmed = content.trim()100        if (trimmed.isEmpty()) return null101102        // CXF (FIDO Alliance Credential Exchange Format): an object carrying an103        // "accounts" array. Must be checked before the Bitwarden "items" sniff —104        // a CXF file nests "items" under each account and would otherwise105        // misclassify as Bitwarden JSON.106        if (trimmed.startsWith("{") && Regex("\"accounts\"\\s*:\\s*\\[").containsMatchIn(trimmed)) {107            return CXF_JSON108        }109        // Proton Pass JSON: an object carrying a "vaults" map.110        if (trimmed.startsWith("{") && Regex("\"vaults\"\\s*:\\s*\\{").containsMatchIn(trimmed)) {111            return PROTONPASS_JSON112        }113        // Bitwarden JSON: an object carrying an "items" array. Cheap shape check114        // rather than a full parse — the parser does the real decode.115        if (trimmed.startsWith("{") && Regex("\"items\"\\s*:\\s*\\[").containsMatchIn(trimmed)) {116            return BITWARDEN_JSON117        }118        // Any other top-level JSON we don't understand is not a CSV.119        if (trimmed.startsWith("{") || trimmed.startsWith("[")) return null120121        val header = firstNonBlankLine(trimmed) ?: return null122        val cols = splitCsvLine(header).map { it.trim().lowercase() }123        val set = cols.toSet()124125        return when {126            set.containsAll(listOf("name", "login_uri", "login_username", "login_password")) -> BITWARDEN_CSV127            // Chrome and Edge share the exact Chromium export header. They are128            // indistinguishable from content alone; default to Chrome, the UI129            // copy says "Chrome/Edge export" so the label is honest.130            cols == listOf("name", "url", "username", "password") -> CHROME_CSV131            set.containsAll(listOf("title", "url", "username", "password")) &&132                (set.contains("otpauth") || set.contains("notes")) -> SAFARI_CSV133            set.containsAll(listOf("title", "url", "username", "password")) -> ONEPASSWORD_CSV134            // Generic: any header with at least a name/title and a password column.135            (set.contains("name") || set.contains("title")) &&136                set.any { it.contains("password") } -> GENERIC_CSV137            else -> null138        }139    }140141    /**142     * Parse [content] as the given [provider]. Provider should come from143     * [detectProvider]; passing an unknown label falls back to generic CSV.144     * Throws nothing — malformed content yields an empty list, which the UI145     * surfaces as the "No entries found" error.146     */147    fun parse(content: String, provider: String): List<ParsedEntry> = when (provider) {148        PROTONPASS_JSON -> parseProtonPassJson(content)149        BITWARDEN_JSON -> parseBitwardenJson(content)150        CXF_JSON -> parseCxfJson(content)151        BITWARDEN_CSV -> parseCsvByHeader(content, BitwardenCsvMap)152        CHROME_CSV, EDGE_CSV -> parseCsvByHeader(content, ChromeCsvMap)153        SAFARI_CSV -> parseCsvByHeader(content, SafariCsvMap)154        ONEPASSWORD_CSV -> parseCsvByHeader(content, OnePasswordCsvMap)155        else -> parseCsvByHeader(content, GenericCsvMap)156    }157158    /** Convenience: detect + parse in one call, returning a sealed result. */159    fun detectAndParse(content: String): ImportParseResult {160        try {161            val provider = detectProvider(content)162                ?: return ImportParseResult.Failure(ImportParseResult.Reason.UNRECOGNISED)163            val entries = parse(content, provider)164            return when {165                entries.isEmpty() -> ImportParseResult.Failure(ImportParseResult.Reason.NO_ENTRIES)166                // M-01: reject an absurd shape before it reaches the preview, where167                // every entry is rendered and then re-encoded into the vault write.168                entries.size > MAX_IMPORT_ENTRIES || entries.any { it.hasOversizeField() } ->169                    ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE)170                else -> ImportParseResult.Success(provider, entries)171            }172        } catch (_: ImportTooLargeException) {173            return ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE)174        }175    }176177    private class ImportTooLargeException : RuntimeException()178179    private fun checkImportEntryCount(count: Int) {180        if (count > MAX_IMPORT_ENTRIES) throw ImportTooLargeException()181    }182183    private fun ParsedEntry.hasOversizeField(): Boolean =184        listOf(title, username, password, url, notes, totp).any { (it?.length ?: 0) > MAX_FIELD_CHARS }185186    // ---- CSV column maps --------------------------------------------------187188    /**189     * Maps lower-cased CSV header names to the [ParsedEntry] field they feed.190     * Each list is tried in order; the first present column wins. This lets one191     * generic row-builder serve every CSV provider.192     */193    private class CsvColumnMap(194        val title: List<String>,195        val username: List<String>,196        val password: List<String>,197        val url: List<String>,198        val notes: List<String>,199        val totp: List<String>,200    )201202    private val BitwardenCsvMap = CsvColumnMap(203        title = listOf("name"),204        username = listOf("login_username"),205        password = listOf("login_password"),206        url = listOf("login_uri"),207        notes = listOf("notes"),208        totp = listOf("login_totp"),209    )210211    private val ChromeCsvMap = CsvColumnMap(212        title = listOf("name"),213        username = listOf("username"),214        password = listOf("password"),215        url = listOf("url"),216        notes = listOf("note", "notes"),217        totp = emptyList(),218    )219220    private val SafariCsvMap = CsvColumnMap(221        title = listOf("title"),222        username = listOf("username"),223        password = listOf("password"),224        url = listOf("url"),225        notes = listOf("notes"),226        totp = listOf("otpauth"),227    )228229    private val OnePasswordCsvMap = CsvColumnMap(230        title = listOf("title"),231        username = listOf("username"),232        password = listOf("password"),233        url = listOf("url", "website"),234        notes = listOf("notes"),235        totp = listOf("otpauth", "one-time password", "totp"),236    )237238    private val GenericCsvMap = CsvColumnMap(239        title = listOf("name", "title"),240        username = listOf("username", "user", "login", "email"),241        password = listOf("password", "login_password", "pass"),242        url = listOf("url", "login_uri", "website", "uri"),243        notes = listOf("notes", "note"),244        totp = listOf("totp", "login_totp", "otpauth"),245    )246247    private fun parseCsvByHeader(content: String, map: CsvColumnMap): List<ParsedEntry> {248        val rows = parseCsv(content)249        if (rows.size < 2) return emptyList()250        val header = rows.first().map { it.trim().lowercase() }251        fun col(names: List<String>): Int = names.firstNotNullOfOrNull { n ->252            header.indexOf(n).takeIf { it >= 0 }253        } ?: -1254255        val ti = col(map.title)256        val ui = col(map.username)257        val pi = col(map.password)258        val urli = col(map.url)259        val ni = col(map.notes)260        val toi = col(map.totp)261262        fun cell(row: List<String>, idx: Int): String? =263            if (idx in row.indices) row[idx].trim().ifEmpty { null } else null264265        return rows.drop(1).mapNotNull { row ->266            if (row.all { it.isBlank() }) return@mapNotNull null267            val title = cell(row, ti)268                ?: cell(row, urli)269                ?: cell(row, ui)270                ?: return@mapNotNull null271            ParsedEntry(272                title = title,273                username = cell(row, ui),274                password = cell(row, pi),275                url = cell(row, urli),276                notes = cell(row, ni),277                totp = cell(row, toi),278            )279        }280    }281282    // ---- Proton Pass JSON -------------------------------------------------283284    private fun parseProtonPassJson(content: String): List<ParsedEntry> {285        val vaults = extractObject(content, "vaults") ?: return emptyList()286        val entries = mutableListOf<ParsedEntry>()287        for (vault in splitTopLevelObjects(vaults.removePrefix("{").removeSuffix("}"))) {288            if (jsonString(vault, "name").equals("recycle bin", ignoreCase = true)) continue289            val itemsArray = extractArray(vault, "items") ?: continue290            for (item in splitTopLevelObjects(itemsArray)) {291                val itemData = extractObject(item, "data") ?: item292                val itemType = jsonString(itemData, "type") ?: jsonString(item, "type")293                if (itemType != "login") continue294                val body = extractObject(itemData, "content") ?: ""295                val metadata = extractObject(itemData, "metadata") ?: ""296                val password = firstNonBlank(jsonString(body, "password"), jsonString(body, "itemPassword"))297                    ?: continue298                entries += ParsedEntry(299                    title = firstNonBlank(300                        jsonString(metadata, "name"),301                        jsonString(item, "name"),302                        jsonString(item, "title"),303                        jsonString(body, "itemName"),304                    ) ?: "Untitled",305                    username = firstNonBlank(306                        jsonString(body, "username"),307                        jsonString(body, "itemUsername"),308                        jsonString(body, "email"),309                        jsonString(body, "itemEmail"),310                        jsonString(item, "aliasEmail"),311                    ),312                    password = password,313                    url = firstNonBlank(extractFirstStringFromArray(body, "urls"), jsonString(body, "url")),314                    notes = firstNonBlank(jsonString(metadata, "note"), jsonString(metadata, "notes")),315                    totp = firstNonBlank(jsonString(body, "totpUri"), jsonString(body, "itemTotpUri")),316                )317            }318        }319        return entries320    }321322    // ---- Bitwarden JSON ---------------------------------------------------323324    /**325     * Minimal extractor for Bitwarden's unencrypted JSON export. Avoids pulling326     * a JSON dependency into this pure module by walking the `items` array with a327     * tolerant brace-aware scanner; only login items (type 1) are imported.328     */329    private fun parseBitwardenJson(content: String): List<ParsedEntry> {330        val itemsArray = extractItemsArray(content) ?: return emptyList()331        val objects = splitTopLevelObjects(itemsArray)332        return objects.mapNotNull { obj ->333            val type = jsonNumber(obj, "type")334            // Bitwarden type 1 == login. Skip cards/notes/identities for now.335            if (type != null && type != 1) return@mapNotNull null336            val name = jsonString(obj, "name") ?: return@mapNotNull null337            val loginBlock = extractObject(obj, "login") ?: ""338            ParsedEntry(339                title = name,340                username = jsonString(loginBlock, "username"),341                password = jsonString(loginBlock, "password"),342                url = extractFirstUri(loginBlock),343                notes = jsonString(obj, "notes"),344                totp = jsonString(loginBlock, "totp"),345                passkeys = parseBitwardenFido2(loginBlock),346            )347        }348    }349350    private fun parseBitwardenFido2(loginBlock: String): List<ParsedPasskey> {351        val credsArray = extractArray(loginBlock, "fido2Credentials") ?: return emptyList()352        val creds = splitTopLevelObjects(credsArray)353        return creds.mapNotNull { cred ->354            val credentialId = jsonString(cred, "credentialId") ?: return@mapNotNull null355            val rpId = jsonString(cred, "rpId") ?: return@mapNotNull null356            val keyValue = jsonString(cred, "keyValue") ?: return@mapNotNull null357            val keyAlgorithm = jsonString(cred, "keyAlgorithm") ?: "ECDSA"358            val keyCurve = jsonString(cred, "keyCurve") ?: "P-256"359            val algorithm = when {360                keyAlgorithm.equals("ECDSA", ignoreCase = true) && keyCurve == "P-256" -> -7361                keyAlgorithm.equals("EdDSA", ignoreCase = true) || keyCurve == "Ed25519" -> -8362                else -> return@mapNotNull null363            }364            val jwk = buildBitwardenJwk(keyValue, algorithm)365            ParsedPasskey(366                credentialId = credentialId,367                rpId = rpId,368                rpName = jsonString(cred, "rpName"),369                privateKeyJwk = jwk,370                algorithm = algorithm,371                userHandle = jsonString(cred, "userHandle"),372                userName = jsonString(cred, "userName"),373                userDisplayName = jsonString(cred, "userDisplayName"),374                signCount = jsonNumber(cred, "counter") ?: 0,375            )376        }377    }378379    private fun buildBitwardenJwk(keyValue: String, algorithm: Int): String = when (algorithm) {380        -7 -> "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + keyValue + "\"}"381        -8 -> "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + keyValue + "\"}"382        else -> "{}"383    }384385    // ---- Credential Exchange Format (CXF / FIDO Alliance) -----------------386387    /**388     * CXF — the JSON shape Google Password Manager (and other managers) export389     * passkeys in: an `accounts` array, each with `items`, each with390     * `credentials`. Only `type == "passkey"` credentials are imported. Mirrors391     * the browser extension's `parseCredentialExchangeJSON`: the same six fields392     * are required, and an unreadable private key skips just that credential393     * rather than failing the whole import.394     */395    private fun parseCxfJson(content: String): List<ParsedEntry> {396        val accounts = extractArray(content, "accounts") ?: return emptyList()397        val entries = mutableListOf<ParsedEntry>()398        for (account in splitTopLevelObjects(accounts)) {399            val items = extractArray(account, "items") ?: continue400            for (item in splitTopLevelObjects(items)) {401                val creds = extractArray(item, "credentials") ?: continue402                val itemTitle = jsonString(item, "title")403                val passkeys = mutableListOf<ParsedPasskey>()404                var firstRpId: String? = null405                for (cred in splitTopLevelObjects(creds)) {406                    if (!jsonString(cred, "type").equals("passkey", ignoreCase = true)) continue407                    val credentialId = jsonString(cred, "credentialId") ?: continue408                    val rpId = jsonString(cred, "rpId") ?: continue409                    val userName = jsonString(cred, "username") ?: continue410                    val userDisplayName = jsonString(cred, "userDisplayName") ?: continue411                    val userHandle = jsonString(cred, "userHandle") ?: continue412                    val key = jsonString(cred, "key") ?: continue413                    val (jwk, algorithm) = cxfKeyToJwk(key) ?: continue414                    if (firstRpId == null) firstRpId = rpId415                    passkeys += ParsedPasskey(416                        credentialId = credentialId,417                        rpId = rpId,418                        rpName = itemTitle ?: rpId,419                        privateKeyJwk = jwk,420                        algorithm = algorithm,421                        userHandle = userHandle,422                        userName = userName,423                        userDisplayName = userDisplayName,424                        signCount = 0,425                    )426                }427                if (passkeys.isEmpty()) continue428                // Passkey-only: leave login fields null so the UI does not spawn429                // an empty login entry (the browser creates a standalone passkey).430                entries += ParsedEntry(431                    title = itemTitle ?: firstRpId ?: "Untitled",432                    url = firstRpId?.let { "https://$it" },433                    passkeys = passkeys,434                )435            }436        }437        return entries438    }439440    /**441     * Decode a CXF `key` (base64url PKCS#8 DER) into the minimal signing JWK the442     * assertion engine consumes (`kty`/`crv`/`d`). Ed25519 vs EC P-256 is read443     * from the PKCS#8 AlgorithmIdentifier OID. Returns null for any key we cannot444     * parse — the caller skips that credential.445     */446    private fun cxfKeyToJwk(keyB64url: String): Pair<String, Int>? = runCatching {447        val der = java.util.Base64.getUrlDecoder().decode(keyB64url)448        val r = DerReader(der)449        r.expect(0x30); r.readLength()                       // PrivateKeyInfo SEQUENCE450        r.expect(0x02); r.skip(r.readLength())               // version INTEGER451        r.expect(0x30)                                       // AlgorithmIdentifier452        val algLen = r.readLength()453        val algEnd = r.pos + algLen                          // pos is past the length byte here454        r.expect(0x06); val oid = r.readValue(r.readLength())455        r.pos = algEnd456        r.expect(0x04); val privKey = r.readValue(r.readLength())  // privateKey OCTET STRING457        when {458            oid.contentEquals(ED25519_OID) -> {459                // RFC 8410: privateKey wraps the 32-byte seed as an inner OCTET STRING.460                val inner = DerReader(privKey)461                inner.expect(0x04)462                val d = inner.readValue(inner.readLength())463                "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + b64Url(d) + "\"}" to -8464            }465            oid.contentEquals(EC_PUBLIC_KEY_OID) -> {466                // RFC 5915 ECPrivateKey: SEQUENCE { version, privateKey OCTET STRING, ... }.467                val inner = DerReader(privKey)468                inner.expect(0x30); inner.readLength()469                inner.expect(0x02); inner.skip(inner.readLength())  // version == 1470                inner.expect(0x04)471                val d = inner.readValue(inner.readLength())472                "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + b64Url(d) + "\"}" to -7473            }474            else -> null475        }476    }.getOrNull()477478    private fun b64Url(bytes: ByteArray): String =479        java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)480481    // PKCS#8 AlgorithmIdentifier OIDs (DER content, without the 06 <len> prefix).482    private val ED25519_OID = byteArrayOf(0x2B, 0x65, 0x70)                                          // 1.3.101.112483    private val EC_PUBLIC_KEY_OID = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01) // 1.2.840.10045.2.1484485    /** Minimal DER tag-length-value walker — just enough for PKCS#8 private keys. */486    private class DerReader(private val bytes: ByteArray) {487        var pos = 0488        fun expect(tag: Int) {489            if (pos >= bytes.size || (bytes[pos].toInt() and 0xFF) != tag) {490                throw IllegalArgumentException("unexpected DER tag at $pos")491            }492            pos++493        }494        fun readLength(): Int {495            val first = bytes[pos++].toInt() and 0xFF496            if (first < 0x80) return first497            var len = 0498            repeat(first and 0x7F) { len = (len shl 8) or (bytes[pos++].toInt() and 0xFF) }499            return len500        }501        fun readValue(len: Int): ByteArray = bytes.copyOfRange(pos, pos + len).also { pos += len }502        fun skip(len: Int) { pos += len }503    }504505    private fun extractItemsArray(content: String): String? {506        return extractArray(content, "items")507    }508509    private fun extractArray(content: String, keyName: String): String? {510        val key = Regex("\"${Regex.escape(keyName)}\"\\s*:\\s*\\[").find(content) ?: return null511        val start = key.range.last // index of '['512        var depth = 0513        var i = start514        var inStr = false515        var esc = false516        while (i < content.length) {517            val c = content[i]518            if (inStr) {519                when {520                    esc -> esc = false521                    c == '\\' -> esc = true522                    c == '"' -> inStr = false523                }524            } else when (c) {525                '"' -> inStr = true526                '[' -> depth++527                ']' -> { depth--; if (depth == 0) return content.substring(start + 1, i) }528            }529            i++530        }531        return null532    }533534    /** Split a JSON array body into its top-level `{...}` object substrings. */535    private fun splitTopLevelObjects(arrayBody: String): List<String> {536        val out = mutableListOf<String>()537        var depth = 0538        var startIdx = -1539        var inStr = false540        var esc = false541        for (i in arrayBody.indices) {542            val c = arrayBody[i]543            if (inStr) {544                when {545                    esc -> esc = false546                    c == '\\' -> esc = true547                    c == '"' -> inStr = false548                }549                continue550            }551            when (c) {552                '"' -> inStr = true553                '{' -> { if (depth == 0) startIdx = i; depth++ }554                '}' -> {555                    depth--556                    if (depth == 0 && startIdx >= 0) {557                        out.add(arrayBody.substring(startIdx, i + 1))558                        checkImportEntryCount(out.size)559                        startIdx = -1560                    }561                }562            }563        }564        return out565    }566567    /** Extract the substring of a nested object value for [key], braces balanced. */568    private fun extractObject(obj: String, key: String): String? {569        val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\\{").find(obj) ?: return null570        var depth = 0571        var inStr = false572        var esc = false573        var i = m.range.last // index of '{'574        val start = i575        while (i < obj.length) {576            val c = obj[i]577            if (inStr) {578                when {579                    esc -> esc = false580                    c == '\\' -> esc = true581                    c == '"' -> inStr = false582                }583            } else when (c) {584                '"' -> inStr = true585                '{' -> depth++586                '}' -> { depth--; if (depth == 0) return obj.substring(start, i + 1) }587            }588            i++589        }590        return null591    }592593    private fun extractFirstUri(loginBlock: String): String? {594        // login.uris is [{ "match": null, "uri": "https://..." }, ...]595        val uriInArray = Regex("\"uris\"\\s*:\\s*\\[(.*?)]", RegexOption.DOT_MATCHES_ALL)596            .find(loginBlock)?.groupValues?.get(1)597        if (uriInArray != null) {598            jsonString(uriInArray, "uri")?.let { return it }599        }600        return jsonString(loginBlock, "uri")601    }602603    private fun extractFirstStringFromArray(scope: String, key: String): String? {604        val body = extractArray(scope, key) ?: return null605        return Regex("\"((?:[^\"\\\\]|\\\\.)*)\"").find(body)606            ?.groupValues607            ?.get(1)608            ?.let(::unescapeJson)609            ?.ifEmpty { null }610    }611612    private fun jsonString(scope: String, key: String): String? {613        val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(scope)614            ?: return null615        return unescapeJson(m.groupValues[1]).ifEmpty { null }616    }617618    private fun jsonNumber(scope: String, key: String): Int? {619        val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(scope) ?: return null620        return m.groupValues[1].toIntOrNull()621    }622623    private fun firstNonBlank(vararg values: String?): String? =624        values.firstOrNull { !it.isNullOrBlank() }?.trim()625626    private fun unescapeJson(s: String): String {627        val sb = StringBuilder(s.length)628        var i = 0629        while (i < s.length) {630            val c = s[i]631            if (c == '\\' && i + 1 < s.length) {632                when (val n = s[i + 1]) {633                    'n' -> sb.append('\n')634                    't' -> sb.append('\t')635                    'r' -> sb.append('\r')636                    '"' -> sb.append('"')637                    '\\' -> sb.append('\\')638                    '/' -> sb.append('/')639                    'u' -> {640                        if (i + 5 < s.length) {641                            val code = s.substring(i + 2, i + 6).toIntOrNull(16)642                            if (code != null) { sb.append(code.toChar()); i += 6; continue }643                        }644                        sb.append(n)645                    }646                    else -> sb.append(n)647                }648                i += 2649            } else {650                sb.append(c)651                i++652            }653        }654        return sb.toString()655    }656657    // ---- RFC-4180-ish CSV --------------------------------------------------658659    /**660     * Parse a whole CSV document into rows of fields. Handles quoted fields,661     * embedded commas, doubled quotes ("" -> "), and newlines inside quotes662     * (CRLF or LF). Trailing blank line is ignored.663     */664    fun parseCsv(content: String): List<List<String>> {665        val rows = mutableListOf<List<String>>()666        var field = StringBuilder()667        var row = mutableListOf<String>()668        var inQuotes = false669        var i = 0670        val n = content.length671672        fun endField() { row.add(field.toString()); field = StringBuilder() }673        fun endRow() {674            endField()675            checkImportEntryCount(rows.size + 1)676            rows.add(row)677            row = mutableListOf()678        }679680        while (i < n) {681            val c = content[i]682            if (inQuotes) {683                when {684                    c == '"' && i + 1 < n && content[i + 1] == '"' -> { field.append('"'); i++ }685                    c == '"' -> inQuotes = false686                    else -> field.append(c)687                }688            } else {689                when (c) {690                    '"' -> inQuotes = true691                    ',' -> endField()692                    '\r' -> { /* swallow; \n handles row end */ }693                    '\n' -> endRow()694                    else -> field.append(c)695                }696            }697            i++698        }699        // Flush trailing field/row if the file does not end with a newline, or if700        // any content accumulated after the last newline.701        if (field.isNotEmpty() || row.isNotEmpty()) endRow()702        // Drop a final fully-empty row produced by a trailing newline.703        return rows.filterNot { it.size == 1 && it[0].isEmpty() }704    }705706    /** Split a single CSV line into fields (used only for header sniffing). */707    fun splitCsvLine(line: String): List<String> =708        parseCsv(line).firstOrNull() ?: emptyList()709710    private fun firstNonBlankLine(s: String): String? =711        s.lineSequence().firstOrNull { it.isNotBlank() }712}