# HG changeset patch # User David Barts # Date 1663732341 25200 # Node ID 711cc42e96d71c42a8270b8609c6a70d0a9466a9 # Parent ad997df1f560cfdb9d3273bdd5dfd37049540ddf Got the list subcommand working, but needs efficiency improvements. diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt Tue Sep 20 20:52:21 2022 -0700 @@ -1,9 +1,6 @@ package name.blackcap.passman -import org.apache.commons.cli.CommandLine -import org.apache.commons.cli.DefaultParser -import org.apache.commons.cli.Options -import org.apache.commons.cli.ParseException +import org.apache.commons.cli.* import kotlin.system.exitProcess class CreateSubcommand(): Subcommand() { @@ -21,6 +18,10 @@ } catch (e: ParseException) { die(e.message ?: "syntax error", 2) } + if (commandLine.hasOption("help")) { + HelpFormatter().printHelp("$SHORTNAME createJv", options) + exitProcess(0) + } checkArguments() val db = Database.open() diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/Database.kt --- a/src/main/kotlin/name/blackcap/passman/Database.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/Database.kt Tue Sep 20 20:52:21 2022 -0700 @@ -120,6 +120,12 @@ public fun ResultSet.getDecrypted(columnIndex: Int, encryption: Encryption) = encryption.decrypt(getBytes(columnIndex)) +public fun ResultSet.getDecryptedString(columnLabel: String, encryption: Encryption) = + encryption.decryptToString(getBytes(columnLabel)) + +public fun ResultSet.getDecrypted(columnLabel: String, encryption: Encryption) = + encryption.decrypt(getBytes(columnLabel)) + public fun PreparedStatement.setEncryptedString(columnIndex: Int, value: String, encryption: Encryption) = setBytes(columnIndex, encryption.encryptFromString(value)) diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt Tue Sep 20 20:52:21 2022 -0700 @@ -1,20 +1,23 @@ package name.blackcap.passman +import kotlin.system.exitProcess + class DeleteSubcommand(): Subcommand() { override fun run(args: Array) { if (args.isEmpty()) { die("expecting a site name", 2) } - if (args.size > 1) { - die("unexpected trailing arguments", 2) - } - val nameIn = args[0] val db = Database.open() - db.connection.prepareStatement("delete from passwords where id = ?").use { - it.setLong(1, db.makeKey(nameIn)) - if (it.executeUpdate() == 0) { - die("no record matches ${see(nameIn)}") + var errors = 0 + for (nameIn in args) { + db.connection.prepareStatement("delete from passwords where id = ?").use { + it.setLong(1, db.makeKey(nameIn)) + if (it.executeUpdate() == 0) { + error("no record matches ${see(nameIn)}") + errors++ + } } } + exitProcess(if (errors > 0) 1 else 0) } } \ No newline at end of file diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/Encryption.kt --- a/src/main/kotlin/name/blackcap/passman/Encryption.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/Encryption.kt Tue Sep 20 20:52:21 2022 -0700 @@ -8,6 +8,7 @@ import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory + import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/Files.kt --- a/src/main/kotlin/name/blackcap/passman/Files.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/Files.kt Tue Sep 20 20:52:21 2022 -0700 @@ -34,7 +34,7 @@ /* file names */ -private const val SHORTNAME = "passman" +const val SHORTNAME = "passman" const val MAIN_PACKAGE = "name.blackcap." + SHORTNAME private val HOME = System.getenv("HOME") private val PF_DIR = when (OS.type) { diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/ListSubcommand.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/ListSubcommand.kt Tue Sep 20 20:52:21 2022 -0700 @@ -0,0 +1,157 @@ +package name.blackcap.passman + +import org.apache.commons.cli.* +import java.util.regex.PatternSyntaxException +import kotlin.system.exitProcess + +class ListSubcommand(): Subcommand() { + private companion object { + const val CASE = "case" + const val FIXED = "fixed" + const val HELP = "help" + const val LONG = "long" + const val NAME = "name" + const val NOTES = "notes" + const val USERNAME = "username" + const val ACCESSED = "accessed" + const val CREATED = "created" + const val MODIFIED = "modified" + val FLAG_OPTIONS = listOf( + OptionDescriptor(CASE, "Treat upper and lower case as distinct."), + OptionDescriptor(FIXED, "Match fixed substrings instead of regular expressions."), + OptionDescriptor(HELP, "Print this help message."), + OptionDescriptor(LONG, "Long format listing.") + ) + val STRING_OPTIONS = listOf( + OptionDescriptor(NAME, "Match site name."), + OptionDescriptor(NOTES, "Match notes."), + OptionDescriptor(USERNAME, "Match username.") + ) + val TIME_OPTIONS = listOf( + OptionDescriptor(ACCESSED, "Match time password last read."), + OptionDescriptor(CREATED, "Match time created."), + OptionDescriptor(MODIFIED, "Match time last modified.") + ) + val REDACTED = "(redacted)".toCharArray() + } + private lateinit var commandLine: CommandLine + private val matchers = mutableMapOf Boolean>>() + + private data class OptionDescriptor(val name: String, val help: String) + + override fun run(args: Array) { + parseArgs(args) + runQuery() + } + + private fun parseArgs(args: Array): Unit { + val options = Options().apply { + FLAG_OPTIONS.forEach { + addOption(it.name.first().toString(), it.name, false, it.help) + } + (STRING_OPTIONS + TIME_OPTIONS).forEach { + addOption(Option(null, it.name, true, it.help).apply + { setArgs(Option.UNLIMITED_VALUES) }) + } + } + try { + commandLine = DefaultParser().parse(options, args) + } catch (e: ParseException) { + die(e.message ?: "syntax error", 2) + } + if (commandLine.hasOption(HELP)) { + HelpFormatter().printHelp("$SHORTNAME list", options) + exitProcess(0) + } + + STRING_OPTIONS.forEach { + commandLine.getOptionValues(it.name)?.forEach { value -> + val regexOptions = mutableSetOf() + if (commandLine.hasOption(FIXED)) { + regexOptions.add(RegexOption.LITERAL) + } + if (!commandLine.hasOption(CASE)) { + regexOptions.add(RegexOption.IGNORE_CASE) + } + try { + if (it.name !in matchers) { + matchers[it.name] = mutableListOf<(Any) -> Boolean>() + } + matchers[it.name]!! += { x -> x is String && x.contains(Regex(value, regexOptions)) } + } catch (e: PatternSyntaxException) { + die("${see(value)} - invalid regular expression") + } + } + } + + TIME_OPTIONS.forEach { + commandLine.getOptionValues(it.name)?.forEach { rawValue -> + if (rawValue.isEmpty()) { + die("empty string is not a valid time expression") + } + val (op, exp) = when(rawValue.first()) { + '+', '>' -> Pair('>', rawValue.substring(1)) + '=' -> Pair('=', rawValue.substring(1)) + '-', '<' -> Pair('<', rawValue.substring(1)) + else -> Pair('=', rawValue) + } + val value = parseDateTime(exp) + if (value == null) { + die("${see(rawValue)} - invalid time expression") + throw RuntimeException("will never happen") + } + if (it.name !in matchers) { + matchers[it.name] = mutableListOf<(Any) -> Boolean>() + } + when(op) { + '>' -> matchers[it.name]!! += { x -> x is Long && x > value } + '=' -> matchers[it.name]!! += { x -> x is Long && (x/1000L) == (value/1000L) } + '<' -> matchers[it.name]!! += { x -> x is Long && x < value } + else -> throw RuntimeException("should never happen") + } + } + } + } + + private fun runQuery(): Unit { + val db = Database.open() + + db.connection.prepareStatement("select $NAME, $USERNAME, $NOTES, $CREATED, $MODIFIED, $ACCESSED from passwords").use { + val results = it.executeQuery() + val printer = if (commandLine.hasOption(LONG)) Entry::printLong else Entry::print + var count = 0; + while (results.next()) { + val passed = matchers + .map { entry -> entry.value.fold(true) { + acc, pred -> acc && pred(results.getDecryptedString(entry.key, db.encryption)) } } + .reduceOrNull() { a, b -> a && b } + ?: true + if (passed) { + val entry = Entry( + name = results.getDecryptedString(1, db.encryption), + username = results.getDecryptedString(2, db.encryption), + password = REDACTED, + notes = results.getDecryptedString(3, db.encryption), + created = results.getDate(4), + modified = results.getDate(5), + accessed = results.getDate(6) + ) + if (count > 0) { + println() + } + printer(entry, null) + count++ + } + } + val s = if (count == 1) "" else "s" + if (count > 0) { + println() + } + println("$count record$s found") + } + } +} + +private fun Options.addMultiOption(opt: String?, longOpt: String, hasArg: Boolean, description: String): Unit { + addOption(Option(opt, longOpt, hasArg, description).apply { args = Option.UNLIMITED_VALUES }) +} \ No newline at end of file diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt Tue Sep 20 20:52:21 2022 -0700 @@ -1,9 +1,7 @@ package name.blackcap.passman -import org.apache.commons.cli.CommandLine -import org.apache.commons.cli.DefaultParser -import org.apache.commons.cli.Options -import org.apache.commons.cli.ParseException +import org.apache.commons.cli.* +import kotlin.system.exitProcess class ReadSubcommand(): Subcommand() { private lateinit var commandLine: CommandLine @@ -11,6 +9,7 @@ override fun run(args: Array) { val options = Options().apply { addOption("c", "clipboard", false, "Copy username and password into clipboard.") + addOption("h", "help", false, "Print this help message.") addOption("l", "long", false, "Long format listing.") } try { @@ -18,10 +17,17 @@ } catch (e: ParseException) { die(e.message ?: "syntax error", 2) } + if (commandLine.hasOption("help")) { + HelpFormatter().printHelp("$SHORTNAME read", options) + exitProcess(0) + } val clipboard = commandLine.hasOption("clipboard") val long = commandLine.hasOption("long") - if (commandLine.args.size != 1) { - die("expecting site name", 2) + if (commandLine.args.isEmpty()) { + error("expecting site name") + } + if (commandLine.args.size > 1) { + error("unexpected trailing arguments") } val nameIn = commandLine.args[0]; val db = Database.open() diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/Time.kt --- a/src/main/kotlin/name/blackcap/passman/Time.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/Time.kt Tue Sep 20 20:52:21 2022 -0700 @@ -1,5 +1,25 @@ package name.blackcap.passman +import java.text.ParseException import java.text.SimpleDateFormat val ISO8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") +private val PARSERS = arrayOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").apply { isLenient = false }, + SimpleDateFormat("yyyy-MM-dd'T'HH:mm").apply { isLenient = false }, + SimpleDateFormat("yyyy-MM-dd").apply { isLenient = false }, +) + +fun parseDateTime(unparsed: String): Long? { + for (parser in PARSERS) { + val ret = try { + parser.parse(unparsed) + } catch (e: ParseException) { + null + } + if (ret != null) { + return ret.time + } + } + return null +} diff -r ad997df1f560 -r 711cc42e96d7 src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt --- a/src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt Sun Sep 11 21:29:20 2022 -0700 +++ b/src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt Tue Sep 20 20:52:21 2022 -0700 @@ -1,9 +1,6 @@ package name.blackcap.passman -import org.apache.commons.cli.CommandLine -import org.apache.commons.cli.DefaultParser -import org.apache.commons.cli.Options -import org.apache.commons.cli.ParseException +import org.apache.commons.cli.* import java.sql.Types import java.util.* import kotlin.properties.Delegates @@ -36,6 +33,7 @@ private fun parseArguments(args: Array) { val options = Options().apply { addOption("g", "generate", false, "Use password generator.") + addOption("h", "help", false, "Print this help message.") addOption("l", "length", true, "Length of generated password.") addOption("s", "symbols", false, "Use symbol characters in generated password.") addOption("v", "verbose", false, "Print the generated password.") @@ -45,6 +43,10 @@ } catch (e: ParseException) { die(e.message ?: "syntax error", 2) } + if (commandLine.hasOption("help")) { + HelpFormatter().printHelp("$SHORTNAME update", options) + exitProcess(0) + } checkArguments() db = Database.open() nameIn = commandLine.args[0]