# HG changeset patch # User David Barts # Date 1674408173 28800 # Node ID a38a2a1036c3f86f0ae3282b84dfe2ad50c810b5 # Parent c69665ff37d056e29755b343b6ee3676feb2e8c4 Add import subcommand. diff -r c69665ff37d0 -r a38a2a1036c3 src/main/kotlin/name/blackcap/passman/Ask.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Ask.kt Sun Jan 22 09:22:53 2023 -0800 @@ -0,0 +1,14 @@ +package name.blackcap.passman + +fun askUserIfOkToOverwrite(thisEntry: Entry, otherEntry: Entry): Boolean { + val REDACTED = "(redacted)" + println("EXISTING ENTRY:") + thisEntry.printLong(REDACTED) + println() + println("NEWER ENTRY:") + otherEntry.printLong(REDACTED) + println() + val answer = name.blackcap.passman.readLine("OK to overwrite existing entry? ") + println() + return answer.trimStart().firstOrNull()?.uppercaseChar() in setOf('T', 'Y') +} diff -r c69665ff37d0 -r a38a2a1036c3 src/main/kotlin/name/blackcap/passman/Entry.kt --- a/src/main/kotlin/name/blackcap/passman/Entry.kt Sat Jan 21 15:39:42 2023 -0800 +++ b/src/main/kotlin/name/blackcap/passman/Entry.kt Sun Jan 22 09:22:53 2023 -0800 @@ -1,5 +1,6 @@ package name.blackcap.passman +import java.sql.ResultSet import java.util.* import kotlin.reflect.KProperty import kotlin.reflect.full.declaredMemberProperties @@ -30,6 +31,25 @@ ) } + fun fromDatabase(db: Database, name: String): Entry? { + db.connection.prepareStatement("select name, username, password, notes, created, modified, accessed from passwords where id = ?").use { + it.setLong(1, db.makeKey(name)) + val results = it.executeQuery() + if (!results.next()) { + return null + } + return Entry( + name = results.getDecryptedString(1, db.encryption)!!, + username = results.getDecryptedString(2, db.encryption)!!, + password = results.getDecrypted(3, db.encryption)!!, + notes = results.getDecryptedString(4, db.encryption), + created = results.getDate(5), + modified = results.getDate(6), + accessed = results.getDate(7) + ) + } + } + private fun _genPassword(length: Int, allowSymbols: Boolean, verbose: Boolean): CharArray { val generated = generate(length, allowSymbols) if (verbose) { @@ -47,6 +67,35 @@ private fun _getNotes() = readLine("Notes: ") } + fun insert(db: Database) { + db.connection.prepareStatement("insert into passwords (id, name, username, password, notes, created, modified, accessed) values (?, ?, ?, ?, ?, ?, ?, ?)") + .use { + it.setLong(1, db.makeKey(name)) + it.setEncryptedString(2, name, db.encryption) + it.setEncryptedString(3, username, db.encryption) + it.setEncrypted(4, password, db.encryption) + it.setEncryptedString(5, notes, db.encryption) + it.setLongOrNull(6, created?.time) + it.setLongOrNull(7, modified?.time) + it.setLongOrNull(8, accessed?.time) + it.executeUpdate() + } + } + + fun update(db: Database) { + db.connection.prepareStatement("update passwords set name = ?, username = ?, password = ?, notes = ?, created = ?, modified = ?, accessed = ? where id = ?").use { + it.setEncryptedString(1, name, db.encryption) + it.setEncryptedString(2, username, db.encryption) + it.setEncrypted(3, password, db.encryption) + it.setEncryptedString(4, notes, db.encryption) + it.setLongOrNull(5, created?.time) + it.setLongOrNull(6, modified?.time) + it.setLongOrNull(7, accessed?.time) + it.setLong(8, db.makeKey(name)) + it.executeUpdate() + } + } + val modifiedOrCreated get() = modified ?: created!! fun print(redactPassword: String? = null) { diff -r c69665ff37d0 -r a38a2a1036c3 src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt Sat Jan 21 15:39:42 2023 -0800 +++ b/src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt Sun Jan 22 09:22:53 2023 -0800 @@ -7,6 +7,7 @@ println("create Create a new username/password pair.") println("delete Delete existing record.") println("help Print this message.") + println("import Import from CSV file.") println("list List records.") println("merge Merge passwords in from another PassMan database.") println("read Retrieve data from existing record.") diff -r c69665ff37d0 -r a38a2a1036c3 src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt Sun Jan 22 09:22:53 2023 -0800 @@ -0,0 +1,151 @@ +package name.blackcap.passman + +import com.opencsv.CSVParserBuilder +import com.opencsv.CSVReaderBuilder +import org.apache.commons.cli.* +import java.io.FileReader +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import kotlin.system.exitProcess + +class ImportSubcommand(): Subcommand() { + private companion object { + val CSV_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").apply { + timeZone = TimeZone.getTimeZone("UTC") + isLenient = false + } + const val ESCAPE = "escape" + const val FORCE = "force" + const val HELP = "help" + const val IGNORE = "ignore" + const val QUOTE = "quote" + const val SEPARATOR = "separator" + const val SKIP = "skip" + const val NFIELDS = 7 + + } + private lateinit var commandLine: CommandLine + private lateinit var db: Database + + /* default option values */ + private var escape = '\\' + private var quote = '"' + private var separator = ',' + + override fun run(args: Array) { + parseArguments(args) + db = Database.open() + try { + doImport() + } catch (e: IOException) { + die(e.message ?: "I/O error") + } + } + + private fun parseArguments(args: Array) { + val options = Options().apply { + addOption("e", ImportSubcommand.ESCAPE, true, "CSV escape character (default $escape).") + addOption("f", ImportSubcommand.FORCE, false, "Do not ask before overwriting.") + addOption("h", ImportSubcommand.HELP, false, "Print this help message.") + addOption("i", ImportSubcommand.IGNORE, false, "Ignore white space before quoted strings.") + addOption("q", ImportSubcommand.QUOTE, true, "CSV string-quoting character (default $quote).") + addOption("s", ImportSubcommand.SEPARATOR, true, "CSV separator character (default $separator).") + addOption("k", ImportSubcommand.SKIP, false, "Skip first line of input.") + } + try { + commandLine = DefaultParser().parse(options, args) + } catch (e: ParseException) { + die(e.message ?: "syntax error", 2) + } + if (commandLine.hasOption(ImportSubcommand.HELP)) { + HelpFormatter().printHelp("$SHORTNAME merge [options] csv_file", options) + exitProcess(0) + } + if (commandLine.args.isEmpty()) { + die("expecting other CSV file name", 2) + } + if (commandLine.args.size > 1) { + die("unexpected trailing arguments", 2) + } + escape = getOptionChar(ImportSubcommand.ESCAPE, escape) + quote = getOptionChar(ImportSubcommand.QUOTE, quote) + separator = getOptionChar(ImportSubcommand.SEPARATOR, separator) + } + + private fun getOptionChar(optionName: String, defaultValue: Char): Char { + val optionValue = commandLine.getOptionValue(optionName) ?: return defaultValue + val ret = optionValue.firstOrNull() + if (ret == null) { + die("--$optionName value must not be empty") + } + return ret!! + } + + private fun doImport() { + val csvParser = CSVParserBuilder() + .withEscapeChar(escape) + .withQuoteChar(quote) + .withSeparator(separator) + .withIgnoreLeadingWhiteSpace(commandLine.hasOption(ImportSubcommand.IGNORE)) + .build() + + val csvReader = CSVReaderBuilder(FileReader(commandLine.args[0])) + .withCSVParser(csvParser) + .build() + + csvReader.use { + if (commandLine.hasOption(ImportSubcommand.SKIP)) { + it.skip(1) + } + it.iterator().forEach { fields -> + val importedEntry = fromCsv(fields) + val thisEntry = Entry.fromDatabase(db, importedEntry.name) + try { + if (okToChange(thisEntry, importedEntry)) { + if (thisEntry == null) { + importedEntry.insert(db) + } else { + importedEntry.update(db) + } + } + } finally { + thisEntry?.password?.clear() + } + } + } + } + + private fun fromCsv(fields: Array): Entry { + if (fields.size != NFIELDS) { + die("expected $NFIELDS fields but got ${fields.size}") + } + return Entry( + name = fields[0], + username = fields[1], + password = fields[2].toCharArray(), + notes = if (saysNull(fields[3])) null else fields[3], + created = parseCsvTime(fields[4]) ?: Date(), + modified = parseCsvTime(fields[5]), + accessed = parseCsvTime(fields[6]) + ) + } + + private fun parseCsvTime(unparsed: String): Date? { + if (saysNull(unparsed)) { + return null + } + try { + return CSV_DATE_FORMAT.parse(unparsed) + } catch (e: ParseException) { + die("${see(unparsed)} - invalid date/time string") + throw e /* kotlin is too stupid to realize this never happens */ + } + } + + private fun okToChange(thisEntry: Entry?, otherEntry: Entry): Boolean = + thisEntry == null || commandLine.hasOption(FORCE) || askUserIfOkToOverwrite(thisEntry, otherEntry) + + private fun saysNull(string: String) = string.lowercase() == "null" + +} \ No newline at end of file diff -r c69665ff37d0 -r a38a2a1036c3 src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt Sat Jan 21 15:39:42 2023 -0800 +++ b/src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt Sun Jan 22 09:22:53 2023 -0800 @@ -2,7 +2,6 @@ import org.apache.commons.cli.* import java.sql.ResultSet -import java.util.* import kotlin.system.exitProcess class MergeSubcommand(): Subcommand() { @@ -53,7 +52,7 @@ val otherEntry = makeEntry(results) val thisEntry = getEntry(db, otherEntry.name) if (thisEntry == null) { - doInsert(otherEntry) + otherEntry.insert(db) } else { doCompare(thisEntry, otherEntry) thisEntry.password.clear() @@ -81,21 +80,6 @@ } } - private fun doInsert(entry: Entry) { - db.connection.prepareStatement("insert into passwords (id, name, username, password, notes, created, modified, accessed) values (?, ?, ?, ?, ?, ?, ?, ?)") - .use { - it.setLong(1, db.makeKey(entry.name)) - it.setEncryptedString(2, entry.name, db.encryption) - it.setEncryptedString(3, entry.username, db.encryption) - it.setEncrypted(4, entry.password, db.encryption) - it.setEncryptedString(5, entry.notes, db.encryption) - it.setLongOrNull(6, entry.created?.time) - it.setLongOrNull(7, entry.modified?.time) - it.setLongOrNull(8, entry.accessed?.time) - it.executeUpdate() - } - } - private fun doCompare(thisEntry: Entry, otherEntry: Entry) { if (otherEntry.modifiedOrCreated.after(thisEntry.modifiedOrCreated) && okToChange(thisEntry, otherEntry)) { db.connection.prepareStatement("update passwords set name = ?, username = ?, password = ?, notes = ?, modified = ? where id = ?").use { @@ -110,20 +94,6 @@ } } - private fun okToChange(thisEntry: Entry, otherEntry: Entry): Boolean { - if (commandLine.hasOption(MergeSubcommand.FORCE)) { - return true - } - val REDACTED = "(redacted)" - println("EXISTING ENTRY:") - thisEntry.printLong(REDACTED) - println() - println("NEWER ENTRY:") - otherEntry.printLong(REDACTED) - println() - val answer = name.blackcap.passman.readLine("OK to overwrite existing entry? ") - println() - return answer.trimStart().firstOrNull()?.uppercaseChar() in setOf('T', 'Y') - } - + private fun okToChange(thisEntry: Entry, otherEntry: Entry): Boolean = + commandLine.hasOption(FORCE) || askUserIfOkToOverwrite(thisEntry, otherEntry) } diff -r c69665ff37d0 -r a38a2a1036c3 src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt Sat Jan 21 15:39:42 2023 -0800 +++ b/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt Sun Jan 22 09:22:53 2023 -0800 @@ -37,44 +37,31 @@ } val nameIn = commandLine.args[0]; val db = Database.open() - val id = db.makeKey(nameIn) - - db.connection.prepareStatement("select name, username, password, notes, created, modified, accessed from passwords where id = ?").use { - it.setLong(1, id) - val result = it.executeQuery() - if (!result.next()) { - die("no record matches ${see(nameIn)}") + val entry = Entry.fromDatabase(db, nameIn) + if (entry == null) { + die("no record matches ${see(nameIn)}") + return // Kotlin is too stupid to realize we never get here + } + try { + print(ALT_SB + CLEAR) + val redaction = if (commandLine.hasOption(CLIPBOARD)) { "(in clipboard)" } else { null } + if (commandLine.hasOption(LONG)) { + entry.printLong(redaction) + } else { + entry.print(redaction) } - val entry = Entry( - name = result.getDecryptedString(1, db.encryption)!!, - username = result.getDecryptedString(2, db.encryption)!!, - password = result.getDecrypted(3, db.encryption)!!, - notes = result.getDecryptedString(4, db.encryption), - created = result.getDate(5), - modified = result.getDate(6), - accessed = result.getDate(7) - ) - try { - print(ALT_SB + CLEAR) - val redaction = if (commandLine.hasOption(CLIPBOARD)) { "(in clipboard)" } else { null } - if (commandLine.hasOption(LONG)) { - entry.printLong(redaction) - } else { - entry.print(redaction) - } - if (commandLine.hasOption(CLIPBOARD)) { - writeToClipboard(entry.password) - } - name.blackcap.passman.readLine("Press ENTER to continue: ") - } finally { - print(CLEAR + NORM_SB) - entry.password.clear() + if (commandLine.hasOption(CLIPBOARD)) { + writeToClipboard(entry.password) } + name.blackcap.passman.readLine("Press ENTER to continue: ") + } finally { + print(CLEAR + NORM_SB) + entry.password.clear() } db.connection.prepareStatement("update passwords set accessed = ? where id = ?").use { it.setLong(1, System.currentTimeMillis()) - it.setLong(2, id) + it.setLong(2, db.makeKey(nameIn)) it.execute() } }