CsvExport.kt
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}