view src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt @ 13:302d224bbd57

Improve help messages and csv error reportage.
author David Barts <n5jrn@me.com>
date Tue, 24 Jan 2023 20:13:13 -0800
parents cbe4c797c9a6
children ea65ab890f66
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 val fields = StringBuilder()
    private val fieldValues = mutableListOf<Any?>()

    private companion object {
        const val GENERATE = "generate"
        const val HELP = "help"
        const val LENGTH = "length"
        const val SYMBOLS = "symbols"
        const val VERBOSE = "verbose"
        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()
        try {
            update()
        } finally {
            cleanUp()
        }
    }

    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 (default $DEFAULT_GENERATED_LENGTH).")
            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] name", options)
            exitProcess(0)
        }
        checkArguments()
        db = Database.open()
        nameIn = commandLine.args[0]
        id = db.makeKey(nameIn)
        val rawLength = commandLine.getOptionValue(LENGTH)
        length = try {
            rawLength?.toInt() ?: DEFAULT_GENERATED_LENGTH
        } catch (e: NumberFormatException) {
            -1
        }
        if (length < MIN_GENERATED_LENGTH) {
            die("${see(rawLength)} - invalid length")
        }
    }

    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 (bad) {
            exitProcess(2);
        }
        if (commandLine.args.isEmpty()) {
            die("expecting site name", 2)
        }
        if (commandLine.args.size > 1) {
            die("unexpected trailing arguments", 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 (commandLine.hasOption(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 cleanUp(): Unit {
        fieldValues.forEach {
            if (it is CharArray) {
                it.clear()
            }
        }
    }

    private fun updateOne(name: String): Unit {
        val prompt = name.replaceFirstChar { it.titlecase(Locale.getDefault()) } + ": "
        val value: Any? = if (name in SENSITIVE_FIELDS) {
            updatePassword()
        } 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, commandLine.hasOption(SYMBOLS))
        if (commandLine.hasOption(VERBOSE)) {
            printPassword(newPassword)
        }
        addOne("password", newPassword)
    }

    private fun updatePassword(): CharArray {
        while (true) {
            val pw1 = getPassword("Password: ")
            if (pw1.isEmpty()) {
                return pw1
            }
            val pw2 = getPassword("Verification: ")
            if (pw1 contentEquals pw2) {
                return pw1
            }
            error("mismatch, try again")
        }
    }
}