# HG changeset patch # User David Barts # Date 1719805056 25200 # Node ID 7d80cbcb67bb06bfbdb1771771a8b67f2a76fa50 # Parent 8f3ddebb4295df0b9f33198bb48b7b17723ef463 add shlex-style splitter and tests diff -r 8f3ddebb4295 -r 7d80cbcb67bb pom.xml --- a/pom.xml Tue Apr 04 20:38:52 2023 -0700 +++ b/pom.xml Sun Jun 30 20:37:36 2024 -0700 @@ -111,8 +111,20 @@ org.junit.jupiter + junit-jupiter + 5.10.3 + test + + + org.junit.jupiter junit-jupiter-engine - 5.8.2 + 5.10.3 + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.3 test @@ -128,7 +140,7 @@ org.xerial sqlite-jdbc - 3.36.0.3 + 3.41.2.2 commons-cli @@ -138,7 +150,7 @@ com.opencsv opencsv - 5.5 + 5.9 diff -r 8f3ddebb4295 -r 7d80cbcb67bb src/main/kotlin/name/blackcap/passman/Database.kt --- a/src/main/kotlin/name/blackcap/passman/Database.kt Tue Apr 04 20:38:52 2023 -0700 +++ b/src/main/kotlin/name/blackcap/passman/Database.kt Sun Jun 30 20:37:36 2024 -0700 @@ -52,10 +52,10 @@ private fun init(connection: Connection, masterPassword: CharArray): Encryption { try { connection.createStatement().use { stmt -> - stmt.executeUpdate("create table integers ( name string not null, value integer )") - stmt.executeUpdate("create table reals ( name string not null, value integer )") - stmt.executeUpdate("create table strings ( name string not null, value real )") - stmt.executeUpdate("create table blobs ( name string not null, value blob )") + stmt.executeUpdate("create table integers ( name text not null, value integer )") + stmt.executeUpdate("create table reals ( name text not null, value real )") + stmt.executeUpdate("create table strings ( name text not null, value text )") + stmt.executeUpdate("create table blobs ( name text not null, value blob )") stmt.executeUpdate( "create table passwords (" + "id integer not null primary key, " + diff -r 8f3ddebb4295 -r 7d80cbcb67bb src/main/kotlin/name/blackcap/passman/Shplitter.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/name/blackcap/passman/Shplitter.kt Sun Jun 30 20:37:36 2024 -0700 @@ -0,0 +1,139 @@ +package name.blackcap.passman + +// A state is represented by a state-executing function (see below). +typealias State = (Char) -> Boolean + +// Support for simplified *nix shell style splitting into tokens. We are +// focused on splitting things into tokens only. No variable expansion, +// no ~home expansion, etc. Backslash quotes the next character. Single +// quotes quote everything literally up to the next closing single quote. +// Double quotes quote to the closing double quote but honor backslashes +// (i.e. "\"\nice\"" -> "nice"). No \t, \n, \r etc. backslash escapes +// supported (KISS). +class Shplitter() { + private val QUOTING = setOf(::inSingle, ::inDouble) + private val WHITESPACE = setOf(' ', '\t', '\n') + private var oldStates = mutableListOf() + private var state: State = ::space + private var accum = mutableListOf() + private var current = StringBuilder() + + val complete: Boolean + get() = state == ::space || state == ::nonspace + + // Feeds more input into this tokenizer + fun feed(input: String) : Unit { + for (ch in input) { + while (state(ch)) + ; + } + } + + fun split(): Iterable { + if (complete) { + if (current.isNotEmpty()) { + accum.add(current.toString()) + current.clear() + } + if (state == ::nonspace) { + popState() + } + return accum + } else { + throw IllegalStateException("incomplete quoted expression") + } + } + + // State transitions + + private fun pushState(newState: State): Unit { + oldStates.add(state) + state = newState + } + + private fun popState(): Unit { + state = oldStates.removeLast() + } + + private fun lastState(): State = oldStates.last() + + private fun endQuote(): Unit { + if (lastState() == ::space) { + accum.add(current.toString()) + current.clear() + } + popState() + } + + // States. A state is represented by a function that accepts the + // character currently being processed, and returns whether it should + // immediately transition to the next state without reading a new + // character. + + private fun space(ch: Char): Boolean = + when (ch) { + in WHITESPACE -> { false } + '\'' -> { pushState(::inSingle); false } + '"' -> { pushState(::inDouble); false } + '\\' -> { pushState(::backslash); false } + else -> { pushState(::nonspace); true } + } + + private fun nonspace(ch: Char): Boolean = + when (ch) { + in WHITESPACE -> { + accum.add(current.toString()) + current.clear() + popState() + false + } + '\'' -> { + pushState(::inSingle) + false + } + '"' -> { + pushState(::inDouble) + false + } + '\\' -> { + pushState(::backslash) + false + } + else -> { + current.append(ch) + false + } + } + + private fun inSingle(ch: Char): Boolean = + when (ch) { + '\'' -> { endQuote(); false } + else -> { current.append(ch); false } + } + + private fun inDouble(ch: Char): Boolean = + when (ch) { + '\\' -> { pushState(::backslash); false } + '"' -> { endQuote(); false } + else -> { current.append(ch); false } + } + + private fun backslash(ch: Char): Boolean { + val last = lastState() + if (ch == '\n' && last !in QUOTING) { + // if not quoting, \\n makes a normal whitespace out of command terminator + popState() + return true + } else if (last == ::space) { + // start a new unquoted string no matter what + current.append(ch) + state = ::nonspace + return false + } else { + // continue existing string no matter what + current.append(ch) + popState() + return false + } + } +} diff -r 8f3ddebb4295 -r 7d80cbcb67bb src/test/kotlin/name/blackcap/passman/ShplitterTest.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/test/kotlin/name/blackcap/passman/ShplitterTest.kt Sun Jun 30 20:37:36 2024 -0700 @@ -0,0 +1,119 @@ +package name.blackcap.passman + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ShplitterTest { + private lateinit var fixture: Shplitter + + @Test + fun complete() { + val CASES = arrayOf( + "unquoted", + "\"double quoted\"", + "'single quoted'", + "back\\ slash", + "in\\terior back slash", + "\"first\" word double quoted", + "last word double \"quoted\"", + "'first' word single quoted", + "last word single 'quoted'") + + for (case in CASES) { + val fixture = Shplitter() + fixture.feed(case) + assertTrue(fixture.complete, "should be complete but is not: ${case}") + } + } + + @Test + fun incomplete() { + val CASES = arrayOf( + "ends with backslash\\", + "unclosed double \"quote", + "ends with double quote\"", + "unclosed single 'quote", + "ends with single quote'" + ) + for (case in CASES) { + val fixture = Shplitter() + fixture.feed(case) + assertFalse(fixture.complete, "should not be complete but is: ${case}") + } + } + + // Test cases cribbed from Python shlex source. + data class TestDatum(val from: String, val to: Array) + val TEST_DATA_1 = arrayOf( + TestDatum("foo bar", arrayOf("foo", "bar")), + TestDatum(" foo bar", arrayOf("foo", "bar")), + TestDatum(" foo bar ", arrayOf("foo", "bar")), + TestDatum("foo\tbar\tbla\tfasel", arrayOf("foo", "bar", "bla", "fasel")), + TestDatum("x y z\t\txxxx", arrayOf("x", "y", "z", "xxxx")), + TestDatum("\\x bar", arrayOf("x", "bar")), + TestDatum("\\ x bar", arrayOf(" x", "bar")), + TestDatum("\\ bar", arrayOf(" bar")), + TestDatum("foo \\x bar", arrayOf("foo", "x", "bar")), + TestDatum("foo \\ x bar", arrayOf("foo", " x", "bar")), + TestDatum("foo \\ bar", arrayOf("foo", " bar")), + TestDatum("foo \"bar\" bla", arrayOf("foo", "bar", "bla")), + TestDatum("\"foo\" \"bar\" \"bla\"", arrayOf("foo", "bar", "bla")), + TestDatum("\"foo\" bar \"bla\"", arrayOf("foo", "bar", "bla")), + TestDatum("\"foo\" bar bla", arrayOf("foo", "bar", "bla")), + TestDatum("foo 'bar' bla", arrayOf("foo", "bar", "bla")), + TestDatum("'foo' 'bar' 'bla'", arrayOf("foo", "bar", "bla")), + TestDatum("'foo' bar 'bla'", arrayOf("foo", "bar", "bla")), + TestDatum("'foo' bar bla", arrayOf("foo", "bar", "bla")), + TestDatum("blurb foo\"bar\"bar\"fasel\" baz", arrayOf("blurb", "foobarbarfasel", "baz")), + TestDatum("blurb foo'bar'bar'fasel' baz", arrayOf("blurb", "foobarbarfasel", "baz")), + TestDatum("\"\"", arrayOf("")), + TestDatum("''", arrayOf("")), + TestDatum("foo \"\" bar", arrayOf("foo", "", "bar")), + TestDatum("foo '' bar", arrayOf("foo", "", "bar")), + TestDatum("foo \"\" \"\" \"\" bar", arrayOf("foo", "", "", "", "bar")), + TestDatum("foo '' '' '' bar", arrayOf("foo", "", "", "", "bar")), + TestDatum("\"foo\\ bar\"", arrayOf("foo bar"))); + val TEST_DATA_2 = arrayOf( + TestDatum("\"foo\\\\ bar\"", arrayOf("foo\\ bar")), + TestDatum("\"foo\\\\ bar\\\\\"", arrayOf("foo\\ bar\\")), + TestDatum("\"foo\\\\\" bar\\\"", arrayOf("foo\\", "bar\"")), + TestDatum("\"foo\\\\ bar\" dfadf", arrayOf("foo\\ bar", "dfadf")), + TestDatum("\"foo\\\\\\ bar\\\" dfadf\"", arrayOf("foo\\ bar\" dfadf")), + TestDatum("\"foo\\\\\\x bar\" dfadf", arrayOf("foo\\x bar", "dfadf")), + TestDatum("\"foo\\x bar\\\" dfadf\"", arrayOf("foox bar\" dfadf")), + TestDatum("\"foo\\x\"", arrayOf("foox")), + TestDatum("\"foo\\ \"", arrayOf("foo ")), + TestDatum("foo\\ xx", arrayOf("foo xx")), + TestDatum("foo\\ x\\x", arrayOf("foo xx")), + TestDatum("foo\\ x\\x\\\"\"\"", arrayOf("foo xx\"")), + TestDatum("\"foo\\ x\\x\"", arrayOf("foo xx")), + TestDatum("\"foo\\ x\\x\\\\\"", arrayOf("foo xx\\")), + TestDatum("\"foo\\ x\\x\\\\\"\"foobar\"", arrayOf("foo xx\\foobar")), + TestDatum("\"foo\\ x\\x\\\\\"\\'\"foobar\"", arrayOf("foo xx\\'foobar")), + TestDatum("\"foo\\ x\\x\\\\\"\\'\"fo'obar\" 'don'\\''t'", arrayOf("foo xx\\'foobar\" don\\t")), + TestDatum("'foo\\ bar'", arrayOf("foo\\ bar")), + TestDatum("'foo\\\\ bar'", arrayOf("foo\\\\ bar")), + TestDatum("foo\\ bar", arrayOf("foo bar")), + TestDatum("foo#bar\\nbaz", arrayOf("foo#barnbaz")), + TestDatum(":-) ;-)", arrayOf(":-)", ":-)")), + TestDatum("áéíóú", arrayOf("áéíóú")) + ) + + fun runArray(testData: Array) { + for (testDatum in testData) { + val s = Shplitter() + s.feed(testDatum.from) + assertTrue(s.complete, "${testDatum.from}: should be complete, is not") + val split = s.split().toList() + val expecting = testDatum.to.asList() + assertTrue(split == expecting, "${testDatum.from}: expected ${expecting}, got $split") + } + } + + @Test + fun split() { + runArray(TEST_DATA_1) + runArray(TEST_DATA_2) + } +}