changeset 12:a38a2a1036c3

Add import subcommand.
author David Barts <n5jrn@me.com>
date Sun, 22 Jan 2023 09:22:53 -0800
parents c69665ff37d0
children 302d224bbd57
files src/main/kotlin/name/blackcap/passman/Ask.kt src/main/kotlin/name/blackcap/passman/Entry.kt src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt
diffstat 6 files changed, 237 insertions(+), 65 deletions(-) [+]
line wrap: on
line diff
--- /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')
+}
--- 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) {
--- 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.")
--- /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<String>) {
+        parseArguments(args)
+        db = Database.open()
+        try {
+            doImport()
+        } catch (e: IOException) {
+            die(e.message ?: "I/O error")
+        }
+    }
+
+    private fun parseArguments(args: Array<String>) {
+        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<String>): 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
--- 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)
 }
--- 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()
         }
     }