view src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt @ 6:711cc42e96d7

Got the list subcommand working, but needs efficiency improvements.
author David Barts <n5jrn@me.com>
date Tue, 20 Sep 2022 20:52:21 -0700
parents eafa3779aef8
children 698c4a3d758d
line wrap: on
line source

package name.blackcap.passman

import org.apache.commons.cli.*
import java.sql.Types
import java.util.*
import kotlin.properties.Delegates
import kotlin.system.exitProcess

class UpdateSubcommand(): Subcommand() {
    private lateinit var commandLine: CommandLine
    private lateinit var db: Database
    private lateinit var nameIn: String
    private var id by Delegates.notNull<Long>()
    private var length by Delegates.notNull<Int>()
    private var generate by Delegates.notNull<Boolean>()
    private var allowSymbols by Delegates.notNull<Boolean>()
    private var verbose by Delegates.notNull<Boolean>()
    private val fields = StringBuilder()
    private val fieldValues = mutableListOf<Any?>()

    private companion object {
        const val NULL_SPECIFIED = "."
        val NULLABLE_FIELDS = setOf<String>("notes")
        val SENSITIVE_FIELDS = setOf<String>("password")
    }

    override fun run(args: Array<String>) {
        parseArguments(args)
        checkDatabase()
        update()
    }

    private fun parseArguments(args: Array<String>) {
        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.")
        }
        try {
            commandLine = DefaultParser().parse(options, args)
        } 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]
        id = db.makeKey(nameIn)
        length = commandLine.getOptionValue("length").let { rawLength ->
            try {
                rawLength?.toInt() ?: DEFAULT_GENERATED_LENGTH
            } catch (e: NumberFormatException) {
                die("${see(rawLength)} - invalid length")
                -1  /* will never happen */
            }
        }
        generate = commandLine.hasOption("generate")
        allowSymbols = commandLine.hasOption("symbols")
        verbose = commandLine.hasOption("verbose")
    }

    private fun checkArguments(): Unit {
        var bad = false
        if (!commandLine.hasOption("generate")) {
            for (option in listOf<String>("length", "symbols", "verbose")) {
                if (commandLine.hasOption(option)) {
                    error("--$option requires --generate")
                    bad = true
                }
            }
        }
        if (commandLine.args.isEmpty()) {
            error("expecting site name")
        }
        if (commandLine.args.size > 1) {
            error("unexpected trailing arguments")
        }
        if (bad) {
            exitProcess(2);
        }
    }

    private fun checkDatabase(): Unit {
        db.connection.prepareStatement("select count(*) from passwords where id = ?").use {
            it.setLong(1, id)
            val result = it.executeQuery()
            result.next()
            val count = result.getInt(1)
            if (count < 1) {
                die("no record matches " + see(nameIn))
            }
        }
    }

    private fun update(): Unit {
        updateOne("username")
        if (generate) {
            generatePassword()
        } else {
            updateOne("password")
        }
        updateOne("notes")
        if (fieldValues.isEmpty()) {
            error("no values changed")
            return
        }

        db.connection.prepareStatement("update passwords set modified = ?, $fields where id = ?").use { stmt ->
            stmt.setLong(1, System.currentTimeMillis())
            fieldValues.indices.forEach { fieldIndex ->
                val fieldValue = fieldValues[fieldIndex]
                val columnIndex = fieldIndex + 2
                when (fieldValue) {
                    is String -> stmt.setEncryptedString(columnIndex, fieldValue, db.encryption)
                    is CharArray -> stmt.setEncrypted(columnIndex, fieldValue, db.encryption)
                    null -> stmt.setNull(columnIndex, Types.BLOB)
                    else -> throw RuntimeException("this shouldn't happen")
                }
            }
            stmt.setLong(fieldValues.size + 2, id)
            stmt.execute()
        }
    }

    private fun updateOne(name: String): Unit {
        val prompt = name.replaceFirstChar { it.titlecase(Locale.getDefault()) } + ": "
        val value: Any? = if (name in SENSITIVE_FIELDS) {
            getPassword(prompt, verify = true)
        } else {
            val rawValue = readLine(prompt)
            if (name in NULLABLE_FIELDS && rawValue == NULL_SPECIFIED) {
                null
            } else {
                rawValue
            }
        }

        val noChange = when (value) {
            is String -> value.isEmpty()
            is CharArray -> value.isEmpty()
            else -> false
        }
        if (noChange) {
            return
        }

        addOne(name, value)
    }

    private fun addOne(name: String, value: Any?) {
        if (fields.isNotEmpty()) {
            fields.append(", ")
        }
        fields.append(name)
        fields.append(" = ?")
        fieldValues.add(value)
    }

    private fun generatePassword(): Unit {
        val newPassword = generate(length, allowSymbols)
        if (verbose) {
            printPassword(newPassword)
        }
        addOne("password", newPassword)
    }

}