CsvExport.kt

2.2 KB · raw

1package com.peachpasswords.ui.vault23import com.peachpasswords.vault.VaultEntry45/**6 * Pure-Kotlin CSV serialisation for the C2 Export screen (CSV sub-view).7 *8 * Columns (fixed order, spec line 325): title, username, password, url, notes.9 * `username` falls back to `email` when blank, mirroring PeachScript export.10 *11 * Two layers of quoting are applied, in order:12 *   1. Formula-injection guard (spec line 326): if a field's first character is13 *      one of `= + - @`, TAB (0x09) or CR (0x0D), a leading single quote `'` is14 *      prefixed so spreadsheet apps treat it as text, not a formula. This happens15 *      BEFORE RFC-4180 quoting so the guard char lands inside the quotes.16 *   2. RFC-4180 quoting: a field containing a comma, double-quote, CR or LF is17 *      wrapped in double quotes, and any internal `"` is doubled (`""`).18 *19 * No Compose / Android UI here — unit-testable pure functions.20 */21object CsvExport {2223    private val HEADER = listOf("title", "username", "password", "url", "notes")2425    // Spreadsheet formula triggers. TAB/CR are included because some apps strip26    // leading whitespace and then re-evaluate the first visible char as a formula.27    private val INJECTION_PREFIXES = charArrayOf('=', '+', '-', '@', '\t', '\r')2829    // RFC-4180 line terminator.30    private const val CRLF = "\r\n"3132    fun toCsv(entries: List<VaultEntry>): String {33        val sb = StringBuilder(64 + entries.size * 64)34        sb.append(HEADER.joinToString(",") { encodeField(it) }).append(CRLF)35        for (e in entries) {36            val row = listOf(37                e.title,38                e.username ?: e.email ?: "",39                e.password ?: "",40                e.url ?: "",41                e.notes ?: "",42            )43            sb.append(row.joinToString(",") { encodeField(it) }).append(CRLF)44        }45        return sb.toString()46    }4748    /** Apply the formula-injection guard, then RFC-4180 quoting. */49    fun encodeField(raw: String): String {50        val guarded = if (raw.isNotEmpty() && raw[0] in INJECTION_PREFIXES) "'$raw" else raw51        val needsQuoting = guarded.any { it == ',' || it == '"' || it == '\n' || it == '\r' }52        return if (needsQuoting) "\"" + guarded.replace("\"", "\"\"") + "\"" else guarded53    }54}