package com.peachpasswords.ui.vault import com.peachpasswords.vault.VaultEntry /** * Pure-Kotlin CSV serialisation for the C2 Export screen (CSV sub-view). * * Columns (fixed order, spec line 325): title, username, password, url, notes. * `username` falls back to `email` when blank, mirroring PeachScript export. * * Two layers of quoting are applied, in order: * 1. Formula-injection guard (spec line 326): if a field's first character is * one of `= + - @`, TAB (0x09) or CR (0x0D), a leading single quote `'` is * prefixed so spreadsheet apps treat it as text, not a formula. This happens * BEFORE RFC-4180 quoting so the guard char lands inside the quotes. * 2. RFC-4180 quoting: a field containing a comma, double-quote, CR or LF is * wrapped in double quotes, and any internal `"` is doubled (`""`). * * No Compose / Android UI here — unit-testable pure functions. */ object CsvExport { private val HEADER = listOf("title", "username", "password", "url", "notes") // Spreadsheet formula triggers. TAB/CR are included because some apps strip // leading whitespace and then re-evaluate the first visible char as a formula. private val INJECTION_PREFIXES = charArrayOf('=', '+', '-', '@', '\t', '\r') // RFC-4180 line terminator. private const val CRLF = "\r\n" fun toCsv(entries: List): String { val sb = StringBuilder(64 + entries.size * 64) sb.append(HEADER.joinToString(",") { encodeField(it) }).append(CRLF) for (e in entries) { val row = listOf( e.title, e.username ?: e.email ?: "", e.password ?: "", e.url ?: "", e.notes ?: "", ) sb.append(row.joinToString(",") { encodeField(it) }).append(CRLF) } return sb.toString() } /** Apply the formula-injection guard, then RFC-4180 quoting. */ fun encodeField(raw: String): String { val guarded = if (raw.isNotEmpty() && raw[0] in INJECTION_PREFIXES) "'$raw" else raw val needsQuoting = guarded.any { it == ',' || it == '"' || it == '\n' || it == '\r' } return if (needsQuoting) "\"" + guarded.replace("\"", "\"\"") + "\"" else guarded } }