tidwall 66af8ab094 Fix tile38-cli from freezing with non-quoted geojson
This commit addresses an issue that began on 1.19 where the
deprecated tile38 native line protocol was removed in favor of
the more robust resp protocol. In turn the tile38 cli required
that all args are quoteless or quote escaped.

The commit ensures that the server returns the correct error
message and also loosens the strictness of the need for quoted
arguments in the tile38-cli.

fixes #513
2019-12-11 11:08:33 -07:00

679 lines
15 KiB
Go

package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"github.com/peterh/liner"
"github.com/tidwall/gjson"
"github.com/tidwall/resp"
"github.com/tidwall/tile38/core"
)
func userHomeDir() string {
if runtime.GOOS == "windows" {
home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH")
if home == "" {
home = os.Getenv("USERPROFILE")
}
return home
}
return os.Getenv("HOME")
}
var (
historyFile = filepath.Join(userHomeDir(), ".liner_example_history")
)
type connError struct {
OK bool `json:"ok"`
Err string `json:"err"`
}
var (
hostname = "127.0.0.1"
output = "json"
port = 9851
oneCommand string
raw bool
noprompt bool
tty bool
)
func showHelp() bool {
gitsha := ""
if core.GitSHA == "" || core.GitSHA == "0000000" {
gitsha = ""
} else {
gitsha = " (git:" + core.GitSHA + ")"
}
fmt.Fprintf(os.Stdout, "tile38-cli %s%s\n\n", core.Version, gitsha)
fmt.Fprintf(os.Stdout, "Usage: tile38-cli [OPTIONS] [cmd [arg [arg ...]]]\n")
fmt.Fprintf(os.Stdout, " --raw Use raw formatting for replies (default when STDOUT is not a tty)\n")
fmt.Fprintf(os.Stdout, " --noprompt Do not display a prompt\n")
fmt.Fprintf(os.Stdout, " --tty Force TTY\n")
fmt.Fprintf(os.Stdout, " --resp Use RESP output formatting (default is JSON output)\n")
fmt.Fprintf(os.Stdout, " --json Use JSON output formatting (default is JSON output)\n")
fmt.Fprintf(os.Stdout, " -h <hostname> Server hostname (default: %s)\n", hostname)
fmt.Fprintf(os.Stdout, " -p <port> Server port (default: %d)\n", port)
fmt.Fprintf(os.Stdout, "\n")
return false
}
func parseArgs() bool {
defer func() {
if v := recover(); v != nil {
if v, ok := v.(string); ok && v == "bad arg" {
showHelp()
}
}
}()
args := os.Args[1:]
readArg := func(arg string) string {
if len(args) == 0 {
panic("bad arg")
}
var narg = args[0]
args = args[1:]
return narg
}
badArg := func(arg string) bool {
fmt.Fprintf(os.Stderr, "Unrecognized option or bad number of args for: '%s'\n", arg)
return false
}
for len(args) > 0 {
arg := readArg("")
if arg == "--help" || arg == "-?" {
return showHelp()
}
if !strings.HasPrefix(arg, "-") {
args = append([]string{arg}, args...)
break
}
switch arg {
default:
return badArg(arg)
case "--raw":
raw = true
case "--tty":
tty = true
case "--noprompt":
noprompt = true
case "--resp":
output = "resp"
case "--json":
output = "json"
case "-h":
hostname = readArg(arg)
case "-p":
n, err := strconv.ParseUint(readArg(arg), 10, 16)
if err != nil {
return badArg(arg)
}
port = int(n)
}
}
oneCommand = strings.Join(args, " ")
return true
}
func refusedErrorString(addr string) string {
return fmt.Sprintf("Could not connect to Tile38 at %s: Connection refused", addr)
}
var groupsM = make(map[string][]string)
func jsonOK(msg []byte) bool {
return gjson.GetBytes(msg, "ok").Bool()
}
func main() {
if !parseArgs() {
return
}
if !raw && !tty && runtime.GOOS != "windows" {
fi, err := os.Stdout.Stat()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return
}
raw = (fi.Mode() & os.ModeCharDevice) == 0
}
if len(oneCommand) > 0 && (oneCommand[0] == 'h' || oneCommand[0] == 'H') && strings.Split(strings.ToLower(oneCommand), " ")[0] == "help" {
showHelp()
return
}
addr := fmt.Sprintf("%s:%d", hostname, port)
var conn *client
connDial := func() {
var err error
conn, err = clientDial("tcp", addr)
if err != nil {
if _, ok := err.(net.Error); ok {
fmt.Fprintln(os.Stderr, refusedErrorString(addr))
} else {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
if oneCommand != "" {
os.Exit(1)
}
} else if _, err := conn.Do("output " + output); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
connDial()
livemode := false
aof := false
defer func() {
if livemode {
var err error
if aof {
_, err = io.Copy(os.Stdout, conn.Reader())
fmt.Fprintln(os.Stderr, "")
} else {
var msg []byte
for {
msg, err = conn.readLiveResp()
if err != nil {
break
}
if !raw {
if output == "resp" {
msg = convert2termresp(msg)
} else {
msg = convert2termjson(msg)
}
}
fmt.Fprintln(os.Stderr, string(msg))
}
}
if err != nil && err != io.EOF {
fmt.Fprintln(os.Stderr, err.Error())
}
}
}()
line := liner.NewLiner()
defer line.Close()
var commands []string
for name, command := range core.Commands {
commands = append(commands, name)
groupsM[command.Group] = append(groupsM[command.Group], name)
}
sort.Strings(commands)
var groups []string
for group, arr := range groupsM {
groups = append(groups, "@"+group)
sort.Strings(arr)
groupsM[group] = arr
}
sort.Strings(groups)
line.SetMultiLineMode(false)
line.SetCtrlCAborts(true)
if !(noprompt && tty) {
line.SetCompleter(func(line string) (c []string) {
if strings.HasPrefix(strings.ToLower(line), "help ") {
var nitems []string
nline := strings.TrimSpace(line[5:])
if nline == "" || nline[0] == '@' {
for _, n := range groups {
if strings.HasPrefix(strings.ToLower(n), strings.ToLower(nline)) {
nitems = append(nitems, line[:len(line)-len(nline)]+strings.ToLower(n))
}
}
} else {
for _, n := range commands {
if strings.HasPrefix(strings.ToLower(n), strings.ToLower(nline)) {
nitems = append(nitems, line[:len(line)-len(nline)]+strings.ToUpper(n))
}
}
}
for _, n := range nitems {
if strings.HasPrefix(strings.ToLower(n), strings.ToLower(line)) {
c = append(c, n)
}
}
} else {
for _, n := range commands {
if strings.HasPrefix(strings.ToLower(n), strings.ToLower(line)) {
c = append(c, n)
}
}
}
return
})
}
if f, err := os.Open(historyFile); err == nil {
line.ReadHistory(f)
f.Close()
}
defer func() {
if f, err := os.Create(historyFile); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
} else {
line.WriteHistory(f)
f.Close()
}
}()
for {
if conn == nil {
connDial()
if conn == nil {
continue
}
}
var command string
var err error
if oneCommand == "" {
if raw || noprompt {
command, err = line.Prompt("")
} else {
if conn == nil {
command, err = line.Prompt("not connected> ")
} else {
command, err = line.Prompt(addr + "> ")
}
}
} else {
command = oneCommand
}
if err == nil {
nohist := strings.HasPrefix(command, " ")
command = strings.TrimSpace(command)
if command == "" {
if conn != nil {
_, err := conn.Do("pInG")
if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "broken pipe") {
fmt.Fprintln(os.Stderr, err.Error())
} else {
fmt.Fprintln(os.Stderr, refusedErrorString(addr))
}
conn.wr.Close()
conn = nil
continue
}
}
} else {
if !nohist {
line.AppendHistory(command)
}
if (command[0] == 'e' || command[0] == 'E') && strings.ToLower(command) == "exit" {
return
}
if (command[0] == 'q' || command[0] == 'Q') && strings.ToLower(command) == "quit" {
return
}
if (command[0] == 'h' || command[0] == 'H') && (strings.ToLower(command) == "help" || strings.HasPrefix(strings.ToLower(command), "help")) {
err = help(strings.TrimSpace(command[4:]))
if err != nil {
return
}
continue
}
aof = (command[0] == 'a' || command[0] == 'A') && strings.HasPrefix(strings.ToLower(command), "aof ")
tryAgain:
if conn == nil {
connDial()
if conn == nil {
continue
}
}
msg, err := conn.Do(command)
if err != nil {
if err != io.EOF {
fmt.Fprintln(os.Stderr, err.Error())
}
conn.wr.Close()
conn = nil
goto tryAgain
}
switch strings.ToLower(command) {
case "output resp":
if string(msg) == "+OK\r\n" {
output = "resp"
}
case "output json":
if jsonOK(msg) {
output = "json"
}
}
if output == "resp" &&
(strings.HasPrefix(string(msg), "*3\r\n$10\r\npsubscribe\r\n") ||
strings.HasPrefix(string(msg), "*3\r\n$9\r\nsubscribe\r\n")) {
livemode = true
}
if !raw {
if output == "resp" {
msg = convert2termresp(msg)
} else {
msg = convert2termjson(msg)
}
}
if !livemode && output == "json" {
if gjson.GetBytes(msg, "command").String() == "psubscribe" ||
gjson.GetBytes(msg, "command").String() == "subscribe" ||
string(msg) == liveJSON {
livemode = true
}
}
mustOutput := true
if oneCommand == "" && output == "json" && !jsonOK(msg) {
var cerr connError
if err := json.Unmarshal(msg, &cerr); err == nil {
fmt.Fprintln(os.Stderr, "(error) "+cerr.Err)
mustOutput = false
}
} else if livemode {
fmt.Fprintln(os.Stderr, string(msg))
break // break out of prompt and just feed data to screen
}
if mustOutput {
fmt.Fprintln(os.Stdout, string(msg))
}
}
} else if err == liner.ErrPromptAborted {
return
} else if err == io.EOF {
os.Exit(0)
} else {
fmt.Fprintf(os.Stderr, "Error reading line: %s", err.Error())
}
if oneCommand != "" {
return
}
}
}
func convert2termresp(msg []byte) []byte {
rd := resp.NewReader(bytes.NewBuffer(msg))
out := ""
for {
v, _, err := rd.ReadValue()
if err != nil {
break
}
out += convert2termrespval(v, 0)
}
return []byte(strings.TrimSpace(out))
}
func convert2termjson(msg []byte) []byte {
if msg[0] == '{' {
return msg
}
return bytes.TrimSpace(msg[bytes.IndexByte(msg, '\n')+1:])
}
func convert2termrespval(v resp.Value, spaces int) string {
switch v.Type() {
default:
return v.String()
case resp.BulkString:
if v.IsNull() {
return "(nil)"
}
return "\"" + v.String() + "\""
case resp.Integer:
return "(integer) " + v.String()
case resp.Error:
return "(error) " + v.String()
case resp.Array:
arr := v.Array()
if len(arr) == 0 {
return "(empty list or set)"
}
out := ""
nspaces := spaces + numlen(len(arr))
for i, v := range arr {
if i > 0 {
out += strings.Repeat(" ", spaces)
}
iout := strings.TrimSpace(convert2termrespval(v, nspaces+2))
out += fmt.Sprintf("%d) %s\n", i+1, iout)
}
return out
}
}
func numlen(n int) int {
l := 1
if n < 0 {
l++
n = n * -1
}
for i := 0; i < 1000; i++ {
if n < 10 {
break
}
l++
n = n / 10
}
return l
}
func help(arg string) error {
var groupsA []string
for group := range groupsM {
groupsA = append(groupsA, "@"+group)
}
groups := "Groups: " + strings.Join(groupsA, ", ") + "\n"
if arg == "" {
fmt.Fprintf(os.Stderr, "tile38-cli %s (git:%s)\n", core.Version, core.GitSHA)
fmt.Fprintf(os.Stderr, `Type: "help @<group>" to get a list of commands in <group>`+"\n")
fmt.Fprintf(os.Stderr, ` "help <command>" for help on <command>`+"\n")
if !(noprompt && tty) {
fmt.Fprintf(os.Stderr, ` "help <tab>" to get a list of possible help topics`+"\n")
}
fmt.Fprintf(os.Stderr, ` "quit" to exit`+"\n")
if noprompt && tty {
fmt.Fprintf(os.Stderr, groups)
}
return nil
}
showGroups := false
found := false
if strings.HasPrefix(arg, "@") {
for _, command := range groupsM[arg[1:]] {
fmt.Fprintf(os.Stderr, "%s\n", core.Commands[command].TermOutput(" "))
found = true
}
if !found {
showGroups = true
}
} else {
if command, ok := core.Commands[strings.ToUpper(arg)]; ok {
fmt.Fprintf(os.Stderr, "%s\n", command.TermOutput(" "))
found = true
}
}
if showGroups {
if noprompt && tty {
fmt.Fprintf(os.Stderr, groups)
}
} else if !found {
if noprompt && tty {
help("")
}
}
return nil
}
const liveJSON = `{"ok":true,"live":true}`
type client struct {
wr net.Conn
rd *bufio.Reader
}
func clientDial(network, addr string) (*client, error) {
conn, err := net.Dial(network, addr)
if err != nil {
return nil, err
}
return &client{wr: conn, rd: bufio.NewReader(conn)}, nil
}
func (c *client) Do(command string) ([]byte, error) {
_, err := c.wr.Write(plainToCompat(command))
if err != nil {
return nil, err
}
return c.readResp()
}
func (c *client) readResp() ([]byte, error) {
ch, err := c.rd.Peek(1)
if err != nil {
return nil, err
}
switch ch[0] {
case ':', '+', '-', '{':
return c.readLine()
case '$':
return c.readBulk()
case '*':
return c.readArray()
default:
return nil, fmt.Errorf("invalid response character '%c", ch[0])
}
}
func (c *client) readArray() ([]byte, error) {
out, err := c.readLine()
if err != nil {
return nil, err
}
n, err := strconv.ParseUint(string(bytes.TrimSpace(out[1:])), 10, 64)
if err != nil {
return nil, err
}
for i := 0; i < int(n); i++ {
resp, err := c.readResp()
if err != nil {
return nil, err
}
out = append(out, resp...)
}
return out, nil
}
func (c *client) readBulk() ([]byte, error) {
line, err := c.readLine()
if err != nil {
return nil, err
}
x, err := strconv.ParseInt(string(bytes.TrimSpace(line[1:])), 10, 64)
if err != nil {
return nil, err
}
if x < 0 {
return line, nil
}
out := make([]byte, len(line)+int(x)+2)
if _, err := io.ReadFull(c.rd, out[len(line):]); err != nil {
return nil, err
}
if !bytes.HasSuffix(out, []byte{'\r', '\n'}) {
return nil, errors.New("invalid response")
}
copy(out, line)
return out, nil
}
func (c *client) readLine() ([]byte, error) {
line, err := c.rd.ReadBytes('\r')
if err != nil {
return nil, err
}
ch, err := c.rd.ReadByte()
if err != nil {
return nil, err
}
if ch != '\n' {
return nil, errors.New("invalid response")
}
return append(line, '\n'), nil
}
func (c *client) Reader() io.Reader {
return c.rd
}
func (c *client) readLiveResp() (message []byte, err error) {
return c.readResp()
}
// plainToCompat converts a plain message like "SET fleet truck1 ..." into a
// Tile38 compatible blob.
func plainToCompat(message string) []byte {
var args []string
// search for the beginning of the first argument
for i := 0; i < len(message); i++ {
if message[i] != ' ' {
// first argument found
if message[i] == '"' || message[i] == '\'' {
// using a string caps
s := i
cap := message[i]
for ; i < len(message); i++ {
if message[i] == cap {
if message[i-1] == '\\' {
continue
}
if i == len(message)-1 || message[i+1] == ' ' {
args = append(args, message[s:i+1])
i++
break
}
}
}
} else {
// using plain string, terminated by a space
s := i
var quotes bool
for ; i < len(message); i++ {
if message[i] == '"' || message[i] == '\'' {
quotes = true
}
if i == len(message)-1 || message[i+1] == ' ' {
arg := message[s : i+1]
if quotes {
arg = strconv.Quote(arg)
}
args = append(args, arg)
i++
break
}
}
}
}
}
return []byte(strings.Join(args, " ") + "\r\n")
}