diff --git a/Dockerfile b/Dockerfile index 9ed1b258e..7fc666fd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,9 +21,11 @@ # Dockerfile for guacamole-server # -# The Alpine Linux image that should be used as the basis for the guacd image -# NOTE: Using 3.18 because the required openssl1.1-compat-dev package was -# removed in more recent versions. +# The Alpine Linux image that should be used as the basis for the guacd image. +# NOTE: still pinned to 3.18. Moving to a newer base is a separate, larger +# change (newer Alpine renames packages such as webkit2gtk and ships a CMake +# that rejects the older cmake_minimum_required used by some bundled dependency +# sources), and is out of scope for database protocol support. ARG ALPINE_BASE_IMAGE=3.18 # The target architecture of the build. Valid values are "ARM" and "X86". By @@ -161,7 +163,10 @@ ARG LIBWEBSOCKETS_X86_OPTS="" FROM alpine:${ALPINE_BASE_IMAGE} AS builder ARG BUILD_DIR -# Install build dependencies +# Install build dependencies. openssl-dev (OpenSSL 3) replaces the older +# openssl1.1-compat-dev previously used here: the database client libraries +# require it, and the VNC/OpenSSL 3 incompatibility that originally required +# OpenSSL 1.1 (GUACAMOLE-1741) was fixed upstream in LibVNCServer 0.9.15. RUN apk add --no-cache \ autoconf \ automake \ @@ -170,15 +175,19 @@ RUN apk add --no-cache \ cjson-dev \ cmake \ cunit-dev \ + freetds-dev \ git \ grep \ krb5-dev \ libjpeg-turbo-dev \ libpng-dev \ + libpq-dev \ libtool \ libwebp-dev \ make \ - openssl1.1-compat-dev \ + mariadb-connector-c-dev \ + mongo-c-driver-dev \ + openssl-dev \ pango-dev \ pulseaudio-dev \ sdl2-dev \ diff --git a/Makefile.am b/Makefile.am index 6ce3814a2..9ff59193a 100644 --- a/Makefile.am +++ b/Makefile.am @@ -30,12 +30,18 @@ DIST_SUBDIRS = \ src/libguac \ src/common \ src/common-ssh \ + src/dbshell \ src/terminal \ src/guacd \ src/guacenc \ src/guaclog \ src/pulse \ src/protocols/kubernetes \ + src/protocols/mongodb \ + src/protocols/mssql \ + src/protocols/mysql \ + src/protocols/oracle \ + src/protocols/postgresql \ src/protocols/rdp \ src/protocols/ssh \ src/protocols/telnet \ @@ -53,6 +59,10 @@ if ENABLE_TERMINAL SUBDIRS += src/terminal endif +if ENABLE_DBSHELL +SUBDIRS += src/dbshell +endif + if ENABLE_PULSE SUBDIRS += src/pulse endif @@ -61,6 +71,26 @@ if ENABLE_KUBERNETES SUBDIRS += src/protocols/kubernetes endif +if ENABLE_MONGODB +SUBDIRS += src/protocols/mongodb +endif + +if ENABLE_MSSQL +SUBDIRS += src/protocols/mssql +endif + +if ENABLE_MYSQL +SUBDIRS += src/protocols/mysql +endif + +if ENABLE_ORACLE +SUBDIRS += src/protocols/oracle +endif + +if ENABLE_POSTGRESQL +SUBDIRS += src/protocols/postgresql +endif + if ENABLE_RDP SUBDIRS += src/protocols/rdp endif diff --git a/configure.ac b/configure.ac index 2f2560dd8..65a6c5eaa 100644 --- a/configure.ac +++ b/configure.ac @@ -206,6 +206,10 @@ AC_SUBST([PULSE_INCLUDE], '-I$(top_srcdir)/src/pulse') AC_SUBST([COMMON_SSH_LTLIB], '$(top_builddir)/src/common-ssh/libguac_common_ssh.la') AC_SUBST([COMMON_SSH_INCLUDE], '-I$(top_srcdir)/src/common-ssh') +# Common database shell (REPL) library +AC_SUBST([DBSHELL_LTLIB], '$(top_builddir)/src/dbshell/libguac_dbshell.la') +AC_SUBST([DBSHELL_INCLUDE], '-I$(top_srcdir)/src/dbshell') + # Kubernetes support AC_SUBST([LIBGUAC_CLIENT_KUBERNETES_LTLIB], '$(top_builddir)/src/protocols/kubernetes/libguac-client-kubernetes.la') AC_SUBST([LIBGUAC_CLIENT_KUBERNETES_INCLUDE], '-I$(top_srcdir)/src/protocols/kubernetes') @@ -516,6 +520,12 @@ fi AM_CONDITIONAL([ENABLE_TERMINAL], [test "x${have_terminal}" = "xyes"]) +# +# Database shell (REPL) library, used by the database protocol plugins +# + +AM_CONDITIONAL([ENABLE_DBSHELL], [test "x${have_terminal}" = "xyes"]) + # # libVNCServer # @@ -1420,6 +1430,263 @@ AM_CONDITIONAL([ENABLE_KUBERNETES], [test "x${enable_kubernetes}" = "xyes" \ -a "x${have_ssl}" = "xyes" \ -a "x${have_terminal}" = "xyes"]) +# +# MariaDB Connector/C +# + +have_mariadb=disabled +MARIADB_CFLAGS= +MARIADB_LIBS= +AC_ARG_WITH([mariadb], + [AS_HELP_STRING([--with-mariadb], + [support the MySQL protocol via MariaDB Connector/C @<:@default=check@:>@])], + [], + [with_mariadb=check]) + +if test "x$with_mariadb" != "xno" +then + + PKG_CHECK_MODULES([MARIADB], [libmariadb], + [have_mariadb=yes], + [have_mariadb=no]) + + # Fall back to mariadb_config if pkg-config metadata is unavailable + if test "x${have_mariadb}" = "xno" + then + AC_PATH_PROG([MARIADB_CONFIG], [mariadb_config], [no]) + if test "x$MARIADB_CONFIG" != "xno" + then + MARIADB_CFLAGS=`$MARIADB_CONFIG --include` + MARIADB_LIBS=`$MARIADB_CONFIG --libs` + have_mariadb=yes + fi + fi + + # The non-blocking API of MariaDB Connector/C is required, such that + # session teardown can never block on an unresponsive database server + if test "x${have_mariadb}" = "xyes" + then + + mariadb_saved_LIBS="$LIBS" + LIBS="$LIBS $MARIADB_LIBS" + + AC_CHECK_FUNC([mysql_real_query_start],, + [have_mariadb=no]) + + LIBS="$mariadb_saved_LIBS" + + fi + + if test "x${have_mariadb}" = "xno" + then + AC_MSG_WARN([ + -------------------------------------------- + Unable to find MariaDB Connector/C (with its + non-blocking API). + Support for the MySQL protocol will be disabled. + --------------------------------------------]) + fi + +fi + +AM_CONDITIONAL([ENABLE_MYSQL], [test "x${have_mariadb}" = "xyes" \ + -a "x${have_terminal}" = "xyes"]) + +AC_SUBST(MARIADB_CFLAGS) +AC_SUBST(MARIADB_LIBS) + +# +# libpq +# + +have_libpq=disabled +LIBPQ_CFLAGS= +LIBPQ_LIBS= +AC_ARG_WITH([libpq], + [AS_HELP_STRING([--with-libpq], + [support the PostgreSQL protocol via libpq @<:@default=check@:>@])], + [], + [with_libpq=check]) + +if test "x$with_libpq" != "xno" +then + + PKG_CHECK_MODULES([LIBPQ], [libpq], + [have_libpq=yes], + [have_libpq=no]) + + # Fall back to searching the standard paths if pkg-config metadata is + # unavailable + if test "x${have_libpq}" = "xno" + then + AC_CHECK_LIB([pq], [PQconnectdbParams], + [AC_CHECK_HEADER([libpq-fe.h], + [LIBPQ_LIBS="-lpq" + have_libpq=yes])]) + fi + + if test "x${have_libpq}" = "xno" + then + AC_MSG_WARN([ + -------------------------------------------- + Unable to find libpq. + Support for the PostgreSQL protocol will be disabled. + --------------------------------------------]) + fi + +fi + +AM_CONDITIONAL([ENABLE_POSTGRESQL], [test "x${have_libpq}" = "xyes" \ + -a "x${have_terminal}" = "xyes"]) + +AC_SUBST(LIBPQ_CFLAGS) +AC_SUBST(LIBPQ_LIBS) + +# +# FreeTDS (CT-Library) +# + +have_freetds=disabled +FREETDS_CFLAGS= +FREETDS_LIBS= +AC_ARG_WITH([freetds], + [AS_HELP_STRING([--with-freetds], + [support the SQL Server protocol via FreeTDS @<:@default=check@:>@])], + [], + [with_freetds=check]) + +if test "x$with_freetds" != "xno" +then + + have_freetds=yes + AC_CHECK_LIB([ct], [ct_connect], + [FREETDS_LIBS="-lct"], + [have_freetds=no]) + + if test "x${have_freetds}" = "xyes" + then + AC_CHECK_HEADER([ctpublic.h],, [have_freetds=no]) + fi + + if test "x${have_freetds}" = "xno" + then + AC_MSG_WARN([ + -------------------------------------------- + Unable to find FreeTDS (CT-Library). + Support for the SQL Server protocol will be disabled. + --------------------------------------------]) + fi + +fi + +AM_CONDITIONAL([ENABLE_MSSQL], [test "x${have_freetds}" = "xyes" \ + -a "x${have_terminal}" = "xyes"]) + +AC_SUBST(FREETDS_CFLAGS) +AC_SUBST(FREETDS_LIBS) + +# +# libmongoc +# + +have_mongoc=disabled +MONGOC_CFLAGS= +MONGOC_LIBS= +AC_ARG_WITH([mongoc], + [AS_HELP_STRING([--with-mongoc], + [support the MongoDB protocol via the MongoDB C Driver @<:@default=check@:>@])], + [], + [with_mongoc=check]) + +if test "x$with_mongoc" != "xno" +then + + PKG_CHECK_MODULES([MONGOC], [libmongoc-1.0], + [have_mongoc=yes], + [have_mongoc=no]) + + # Newer releases of the MongoDB C Driver install different pkg-config + # metadata + if test "x${have_mongoc}" = "xno" + then + PKG_CHECK_MODULES([MONGOC], [mongoc2], + [have_mongoc=yes], + [have_mongoc=no]) + fi + + if test "x${have_mongoc}" = "xno" + then + AC_MSG_WARN([ + -------------------------------------------- + Unable to find the MongoDB C Driver (libmongoc). + Support for the MongoDB protocol will be disabled. + --------------------------------------------]) + fi + +fi + +AM_CONDITIONAL([ENABLE_MONGODB], [test "x${have_mongoc}" = "xyes" \ + -a "x${have_terminal}" = "xyes"]) + +AC_SUBST(MONGOC_CFLAGS) +AC_SUBST(MONGOC_LIBS) + +# +# Oracle Call Interface (Oracle Instant Client) +# + +have_oracle=no +ORACLE_CFLAGS= +ORACLE_LIBS= +AC_ARG_WITH([oracle], + [AS_HELP_STRING([--with-oracle@<:@=@:>@], + [support the Oracle Database protocol via the Oracle Instant Client SDK installed at the given path @<:@default=no@:>@])], + [], + [with_oracle=no]) + +if test "x$with_oracle" != "xno" +then + + # If a path was given, assume the Instant Client directory layout + if test "x$with_oracle" != "xyes" + then + ORACLE_CFLAGS="-I$with_oracle/sdk/include" + ORACLE_LIBS="-L$with_oracle -Wl,-rpath,$with_oracle -lclntsh" + else + ORACLE_LIBS="-lclntsh" + fi + + have_oracle=yes + + oracle_saved_CPPFLAGS="$CPPFLAGS" + oracle_saved_LIBS="$LIBS" + CPPFLAGS="$CPPFLAGS $ORACLE_CFLAGS" + LIBS="$LIBS $ORACLE_LIBS" + + AC_CHECK_HEADER([oci.h],, [have_oracle=no]) + AC_CHECK_FUNC([OCIEnvNlsCreate],, [have_oracle=no]) + + CPPFLAGS="$oracle_saved_CPPFLAGS" + LIBS="$oracle_saved_LIBS" + + if test "x${have_oracle}" = "xno" + then + AC_MSG_WARN([ + -------------------------------------------- + Unable to find the Oracle Call Interface (OCI) + headers and library of the Oracle Instant Client SDK. + Support for the Oracle Database protocol will be disabled. + --------------------------------------------]) + fi + +fi + +AM_CONDITIONAL([ENABLE_ORACLE], [test "x${have_oracle}" = "xyes" \ + -a "x${have_terminal}" = "xyes"]) + +AC_SUBST(ORACLE_CFLAGS) +AC_SUBST(ORACLE_LIBS) + # # guacd # @@ -1471,6 +1738,8 @@ AC_CONFIG_FILES([Makefile src/common/tests/Makefile src/common-ssh/Makefile src/common-ssh/tests/Makefile + src/dbshell/Makefile + src/dbshell/tests/Makefile src/terminal/Makefile src/terminal/tests/Makefile src/libguac/Makefile @@ -1485,6 +1754,11 @@ AC_CONFIG_FILES([Makefile src/pulse/Makefile src/protocols/kubernetes/Makefile src/protocols/kubernetes/tests/Makefile + src/protocols/mongodb/Makefile + src/protocols/mssql/Makefile + src/protocols/mysql/Makefile + src/protocols/oracle/Makefile + src/protocols/postgresql/Makefile src/protocols/rdp/Makefile src/protocols/rdp/tests/Makefile src/protocols/ssh/Makefile @@ -1497,6 +1771,11 @@ AC_OUTPUT # AM_COND_IF([ENABLE_KUBERNETES], [build_kubernetes=yes], [build_kubernetes=no]) +AM_COND_IF([ENABLE_MONGODB], [build_mongodb=yes], [build_mongodb=no]) +AM_COND_IF([ENABLE_MSSQL], [build_mssql=yes], [build_mssql=no]) +AM_COND_IF([ENABLE_MYSQL], [build_mysql=yes], [build_mysql=no]) +AM_COND_IF([ENABLE_ORACLE], [build_oracle=yes], [build_oracle=no]) +AM_COND_IF([ENABLE_POSTGRESQL], [build_postgresql=yes], [build_postgresql=no]) AM_COND_IF([ENABLE_RDP], [build_rdp=yes], [build_rdp=no]) AM_COND_IF([ENABLE_SSH], [build_ssh=yes], [build_ssh=no]) AM_COND_IF([ENABLE_TELNET], [build_telnet=yes], [build_telnet=no]) @@ -1545,10 +1824,15 @@ $PACKAGE_NAME version $PACKAGE_VERSION libavcodec .......... ${have_libavcodec} libavformat ......... ${have_libavformat} libavutil ........... ${have_libavutil} + freetds ............. ${have_freetds} + libmariadb .......... ${have_mariadb} + libmongoc ........... ${have_mongoc} + libpq ............... ${have_libpq} libssh2 ............. ${have_libssh2} libssl .............. ${have_ssl} libswscale .......... ${have_libswscale} libtelnet ........... ${have_libtelnet} + oracle (OCI) ........ ${have_oracle} libVNCServer ........ ${have_libvncserver} libvorbis ........... ${have_vorbis} libpulse ............ ${have_pulse} @@ -1559,6 +1843,11 @@ $PACKAGE_NAME version $PACKAGE_VERSION Protocol support: Kubernetes .... ${build_kubernetes} + MongoDB ....... ${build_mongodb} + MySQL ......... ${build_mysql} + Oracle ........ ${build_oracle} + PostgreSQL .... ${build_postgresql} + SQL Server .... ${build_mssql} RDP ........... ${build_rdp} SSH ........... ${build_ssh} Telnet ........ ${build_telnet} diff --git a/src/dbshell/.gitignore b/src/dbshell/.gitignore new file mode 100644 index 000000000..6e6e0a773 --- /dev/null +++ b/src/dbshell/.gitignore @@ -0,0 +1,4 @@ + +# Auto-generated test runner and binary +_generated_runner.c +test_dbshell diff --git a/src/dbshell/Makefile.am b/src/dbshell/Makefile.am new file mode 100644 index 000000000..d5c7a5f24 --- /dev/null +++ b/src/dbshell/Makefile.am @@ -0,0 +1,60 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +noinst_LTLIBRARIES = libguac_dbshell.la +SUBDIRS = . tests + +libguac_dbshell_la_SOURCES = \ + buffer.c \ + client.c \ + history.c \ + line-editor.c \ + repl.c \ + settings.c \ + splitter.c \ + table.c + +noinst_HEADERS = \ + dbshell/buffer.h \ + dbshell/client.h \ + dbshell/dbshell.h \ + dbshell/driver.h \ + dbshell/history.h \ + dbshell/line-editor.h \ + dbshell/settings.h \ + dbshell/splitter.h \ + dbshell/table.h + +libguac_dbshell_la_CFLAGS = \ + -Werror -Wall -pedantic \ + @COMMON_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ + +libguac_dbshell_la_LIBADD = \ + @LIBGUAC_LTLIB@ diff --git a/src/dbshell/buffer.c b/src/dbshell/buffer.c new file mode 100644 index 000000000..0df400bbd --- /dev/null +++ b/src/dbshell/buffer.c @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/buffer.h" + +#include + +#include +#include +#include + +/** + * The initial number of bytes allocated for a buffer upon its first + * append. + */ +#define GUAC_DBSHELL_BUFFER_INITIAL_SIZE 1024 + +void guac_dbshell_buffer_init(guac_dbshell_buffer* buffer) { + buffer->data = NULL; + buffer->length = 0; + buffer->allocated = 0; +} + +void guac_dbshell_buffer_destroy(guac_dbshell_buffer* buffer) { + guac_mem_free(buffer->data); + buffer->data = NULL; + buffer->length = 0; + buffer->allocated = 0; +} + +void guac_dbshell_buffer_append(guac_dbshell_buffer* buffer, + const char* bytes, int length) { + + if (length <= 0) + return; + + /* Expand storage as necessary */ + int required = buffer->length + length; + if (required > buffer->allocated) { + + int allocated = buffer->allocated; + if (allocated == 0) + allocated = GUAC_DBSHELL_BUFFER_INITIAL_SIZE; + + while (allocated < required) + allocated = guac_mem_ckd_mul_or_die(allocated, 2); + + buffer->data = guac_mem_realloc(buffer->data, allocated); + buffer->allocated = allocated; + + } + + memcpy(buffer->data + buffer->length, bytes, length); + buffer->length += length; + +} + +void guac_dbshell_buffer_append_string(guac_dbshell_buffer* buffer, + const char* string) { + guac_dbshell_buffer_append(buffer, string, strlen(string)); +} + +void guac_dbshell_buffer_append_repeat(guac_dbshell_buffer* buffer, char c, + int count) { + + for (int i = 0; i < count; i++) + guac_dbshell_buffer_append(buffer, &c, 1); + +} + +void guac_dbshell_buffer_appendf(guac_dbshell_buffer* buffer, + const char* format, ...) { + + char text[64]; + + va_list args; + va_start(args, format); + int length = vsnprintf(text, sizeof(text), format, args); + va_end(args); + + if (length > (int) sizeof(text) - 1) + length = sizeof(text) - 1; + + if (length > 0) + guac_dbshell_buffer_append(buffer, text, length); + +} diff --git a/src/dbshell/client.c b/src/dbshell/client.c new file mode 100644 index 000000000..0379e6e23 --- /dev/null +++ b/src/dbshell/client.c @@ -0,0 +1,868 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/client.h" +#include "dbshell/dbshell.h" +#include "dbshell/settings.h" +#include "terminal/terminal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/** + * Internal extension of guac_dbshell_client tracking whether the client + * thread was successfully started. This is required so that the free + * handler does not attempt to join a thread which was never created. + */ +typedef struct guac_dbshell_client_internal { + + /** + * The public portion of the client data. + */ + guac_dbshell_client client; + + /** + * Whether the client thread has been successfully started. + */ + bool thread_started; + +} guac_dbshell_client_internal; + +/** + * Sends the current values of all arguments which may be changed + * mid-session (color scheme, font name, and font size) over the given + * socket. + * + * @param client + * The guac_client associated with the database session. + * + * @param socket + * The socket to send the argument values over. + * + * @return + * Always NULL. + */ +static void* guac_dbshell_send_current_argv_batch(guac_client* client, + guac_socket* socket) { + + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + guac_terminal* terminal = dbshell_client->term; + + if (terminal == NULL) + return NULL; + + /* Send current color scheme */ + guac_client_stream_argv(client, socket, "text/plain", + GUAC_DBSHELL_ARGV_COLOR_SCHEME, + guac_terminal_get_color_scheme(terminal)); + + /* Send current font name */ + guac_client_stream_argv(client, socket, "text/plain", + GUAC_DBSHELL_ARGV_FONT_NAME, + guac_terminal_get_font_name(terminal)); + + /* Send current font size */ + char font_size[64]; + snprintf(font_size, sizeof(font_size), "%i", + guac_terminal_get_font_size(terminal)); + guac_client_stream_argv(client, socket, "text/plain", + GUAC_DBSHELL_ARGV_FONT_SIZE, font_size); + + return NULL; + +} + +/** + * Sends the current values of all mid-session arguments to the given user. + * + * @param user + * The user to send the argument values to. + * + * @param data + * Unused. + * + * @return + * Always NULL. + */ +static void* guac_dbshell_send_current_argv(guac_user* user, void* data) { + return guac_dbshell_send_current_argv_batch(user->client, user->socket); +} + +/** + * Handles mid-session updates of the color scheme, font name, and font + * size arguments, applying the new values to the terminal. + * + * @param user + * The user who changed the argument. + * + * @param mimetype + * The mimetype of the data within the argument value stream (ignored). + * + * @param name + * The name of the argument being changed. + * + * @param value + * The new value of the argument. + * + * @param data + * Unused. + * + * @return + * Always zero. + */ +static int guac_dbshell_argv_callback(guac_user* user, const char* mimetype, + const char* name, const char* value, void* data) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + guac_terminal* terminal = dbshell_client->term; + + if (terminal == NULL) + return 0; + + /* Update color scheme */ + if (strcmp(name, GUAC_DBSHELL_ARGV_COLOR_SCHEME) == 0) + guac_terminal_apply_color_scheme(terminal, value); + + /* Update font name */ + else if (strcmp(name, GUAC_DBSHELL_ARGV_FONT_NAME) == 0) + guac_terminal_apply_font(terminal, value, -1, 0); + + /* Update only if font size is sane */ + else if (strcmp(name, GUAC_DBSHELL_ARGV_FONT_SIZE) == 0) { + int size = atoi(value); + if (size > 0) + guac_terminal_apply_font(terminal, NULL, size, + dbshell_client->settings->resolution); + } + + return 0; + +} + +/** + * Handles mouse events, forwarding them to the terminal. + * + * @param user + * The user that originated the mouse event. + * + * @param x + * The X coordinate of the mouse within the display, in pixels. + * + * @param y + * The Y coordinate of the mouse within the display, in pixels. + * + * @param mask + * An integer value representing the current state of each button. + * + * @return + * Always zero. + */ +static int guac_dbshell_user_mouse_handler(guac_user* user, + int x, int y, int mask) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Skip if terminal not yet ready */ + guac_terminal* term = dbshell_client->term; + if (term == NULL) + return 0; + + /* Report mouse position within recording */ + if (dbshell_client->recording != NULL) + guac_recording_report_mouse(dbshell_client->recording, x, y, mask); + + guac_terminal_send_mouse(term, user, x, y, mask); + return 0; + +} + +/** + * Handles key events, forwarding them to the terminal. Additionally, if + * Ctrl+C is pressed while a statement is executing, cancellation of that + * statement is requested. + * + * @param user + * The user that originated the key event. + * + * @param keysym + * The X11 keysym of the key that was pressed or released. + * + * @param pressed + * Non-zero if the key was pressed, zero if it was released. + * + * @return + * Always zero. + */ +static int guac_dbshell_user_key_handler(guac_user* user, int keysym, + int pressed) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Report key state within recording */ + if (dbshell_client->recording != NULL) + guac_recording_report_key(dbshell_client->recording, keysym, + pressed); + + /* Skip if terminal not yet ready */ + guac_terminal* term = dbshell_client->term; + if (term == NULL) + return 0; + + /* Request cancellation of the running statement on Ctrl+C. The 0x03 + * byte resulting from the key event additionally clears the input + * line once the REPL resumes reading. */ + if (pressed && (keysym == 'c' || keysym == 'C') + && guac_terminal_get_mod_ctrl(term) + && dbshell_client->session != NULL) + guac_dbshell_session_cancel(dbshell_client->session); + + guac_terminal_send_key(term, keysym, pressed); + return 0; + +} + +/** + * Handles display size change events, resizing the terminal accordingly. + * + * @param user + * The user that originated the resize request. + * + * @param width + * The new display width, in pixels. + * + * @param height + * The new display height, in pixels. + * + * @return + * Always zero. + */ +static int guac_dbshell_user_size_handler(guac_user* user, int width, + int height) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Skip if terminal not yet ready */ + guac_terminal* terminal = dbshell_client->term; + if (terminal == NULL) + return 0; + + /* Resize terminal (there is no remote terminal to notify) */ + guac_terminal_resize(terminal, width, height); + return 0; + +} + +/** + * Handles inbound clipboard streams, resetting the terminal clipboard to + * receive the new data. + * + * @param user + * The user that opened the clipboard stream. + * + * @param stream + * The stream through which clipboard data will be received. + * + * @param mimetype + * The mimetype of the clipboard data. + * + * @return + * Always zero. + */ +static int guac_dbshell_clipboard_handler(guac_user* user, + guac_stream* stream, char* mimetype); + +/** + * Handles blobs of data received along an inbound clipboard stream, + * appending them to the terminal clipboard. + * + * @param user + * The user that sent the blob. + * + * @param stream + * The clipboard stream. + * + * @param data + * The blob data received. + * + * @param length + * The number of bytes received. + * + * @return + * Always zero. + */ +static int guac_dbshell_clipboard_blob_handler(guac_user* user, + guac_stream* stream, void* data, int length); + +/** + * Handles the end of an inbound clipboard stream. + * + * @param user + * The user that closed the stream. + * + * @param stream + * The clipboard stream. + * + * @return + * Always zero. + */ +static int guac_dbshell_clipboard_end_handler(guac_user* user, + guac_stream* stream); + +static int guac_dbshell_clipboard_handler(guac_user* user, + guac_stream* stream, char* mimetype) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Skip if terminal not yet ready */ + if (dbshell_client->term == NULL) + return 0; + + /* Clear clipboard and prepare for new data */ + guac_terminal_clipboard_reset(dbshell_client->term, mimetype); + + /* Set handlers for clipboard stream */ + stream->blob_handler = guac_dbshell_clipboard_blob_handler; + stream->end_handler = guac_dbshell_clipboard_end_handler; + + /* Report clipboard within recording */ + if (dbshell_client->recording != NULL) + guac_recording_report_clipboard_begin(dbshell_client->recording, + stream, mimetype); + + return 0; + +} + +static int guac_dbshell_clipboard_blob_handler(guac_user* user, + guac_stream* stream, void* data, int length) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Report clipboard blob within recording */ + if (dbshell_client->recording != NULL) + guac_recording_report_clipboard_blob(dbshell_client->recording, + stream, data, length); + + /* Append new data */ + guac_terminal_clipboard_append(dbshell_client->term, data, length); + + return 0; + +} + +static int guac_dbshell_clipboard_end_handler(guac_user* user, + guac_stream* stream) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Report end of clipboard within recording */ + if (dbshell_client->recording != NULL) + guac_recording_report_clipboard_end(dbshell_client->recording, + stream); + + /* Nothing further to do - the clipboard is implemented within the + * terminal */ + + return 0; + +} + +/** + * Handles inbound pipe streams, redirecting the terminal's STDIN to the + * stream if the pipe bears the expected name. + * + * @param user + * The user that opened the pipe stream. + * + * @param stream + * The pipe stream. + * + * @param mimetype + * The mimetype of the data within the stream. + * + * @param name + * The name of the pipe stream. + * + * @return + * Always zero. + */ +static int guac_dbshell_pipe_handler(guac_user* user, guac_stream* stream, + char* mimetype, char* name) { + + guac_client* client = user->client; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Redirect STDIN if pipe has required name */ + if (dbshell_client->term != NULL + && strcmp(name, GUAC_DBSHELL_STDIN_PIPE_NAME) == 0) { + guac_terminal_send_stream(dbshell_client->term, user, stream); + return 0; + } + + /* No other inbound pipe streams are supported */ + guac_protocol_send_ack(user->socket, stream, "No such input stream.", + GUAC_PROTOCOL_STATUS_RESOURCE_NOT_FOUND); + guac_socket_flush(user->socket); + return 0; + +} + +/** + * The client thread of a database session: establishes the connection to + * the database server, runs the interactive REPL until the session ends, + * and disconnects. + * + * @param data + * The guac_client associated with the session. + * + * @return + * Always NULL. + */ +static void* guac_dbshell_client_thread(void* data) { + + guac_client* client = (guac_client*) data; + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + const guac_dbshell_plugin* plugin = dbshell_client->plugin; + guac_dbshell_settings* settings = dbshell_client->settings; + + /* The port of the database server, as a string */ + char port_str[GUAC_USHORT_STRING_BUFSIZE]; + guac_itoa_safe(port_str, sizeof(port_str), settings->port); + + /* Identify the session within the process title, never including the + * password */ + guac_process_title_set_endpoint(plugin->driver->name, + settings->username, settings->hostname, port_str); + + /* Send Wake-on-LAN packet if requested */ + if (settings->wol_send_packet) { + + /* Send the packet and wait for the server to become reachable if + * a wait time was given */ + if (settings->wol_wait_time > 0) { + + guac_client_log(client, GUAC_LOG_DEBUG, "Sending Wake-on-LAN " + "packet, and pausing for %i seconds.", + settings->wol_wait_time); + + if (guac_wol_wake_and_wait(settings->wol_mac_addr, + settings->wol_broadcast_addr, + settings->wol_udp_port, + settings->wol_wait_time, + GUAC_WOL_DEFAULT_CONNECT_RETRIES, + settings->hostname, port_str, + settings->timeout)) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR, + "Failed to send WoL packet, or the database server " + "did not become reachable."); + return NULL; + } + + } + + else if (guac_wol_wake(settings->wol_mac_addr, + settings->wol_broadcast_addr, + settings->wol_udp_port)) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR, + "Failed to send WoL packet."); + return NULL; + } + + } + + /* Set up screen recording, if requested */ + if (settings->recording_path != NULL) { + dbshell_client->recording = guac_recording_create(client, + settings->recording_path, + settings->recording_name, + settings->create_recording_path, + !settings->recording_exclude_output, + !settings->recording_exclude_mouse, + 0, /* Touch events not supported */ + settings->recording_include_keys, + settings->recording_write_existing, + settings->recording_include_clipboard); + } + + /* Create terminal options with required parameters */ + guac_terminal_options* options = guac_terminal_options_create( + settings->width, settings->height, settings->resolution); + + /* Set optional parameters */ + options->clipboard_buffer_size = settings->clipboard_buffer_size; + options->disable_copy = settings->disable_copy; + options->max_scrollback = settings->max_scrollback; + options->font_name = settings->font_name; + options->font_size = settings->font_size; + options->color_scheme = settings->color_scheme; + options->backspace = settings->backspace; + options->func_keys_and_keypad = settings->func_keys_and_keypad; + options->linux_console_keys = + strcmp(settings->terminal_type, "linux") == 0; + + /* Create terminal */ + dbshell_client->term = guac_terminal_create(client, options); + + /* Free options struct now that it's been used */ + guac_mem_free(options); + + /* Fail if terminal init failed */ + if (dbshell_client->term == NULL) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR, + "Terminal initialization failed"); + return NULL; + } + + /* Send current values of exposed arguments to owner only */ + guac_client_for_owner(client, guac_dbshell_send_current_argv, NULL); + + /* Set up typescript, if requested */ + if (settings->typescript_path != NULL) { + guac_terminal_create_typescript(dbshell_client->term, + settings->typescript_path, + settings->typescript_name, + settings->create_typescript_path, + settings->typescript_write_existing); + } + + /* Allow terminal to render, and accept input for credential + * prompting */ + guac_terminal_start(dbshell_client->term); + + /* Create the database session */ + guac_dbshell_session* session = guac_dbshell_session_alloc(client, + dbshell_client->term, plugin->driver, settings); + dbshell_client->session = session; + + /* Establish the connection to the database server */ + if (plugin->driver->connect_handler(session)) { + guac_client_log(client, GUAC_LOG_INFO, "Database connection " + "failed."); + guac_client_stop(client); + return NULL; + } + + guac_client_log(client, GUAC_LOG_INFO, "Database connection " + "successful."); + + /* Run the interactive shell until the session ends */ + guac_dbshell_repl_run(session); + + /* Close the connection to the database server */ + plugin->driver->disconnect_handler(session); + + guac_client_log(client, GUAC_LOG_INFO, "Database session ended."); + guac_client_stop(client); + + return NULL; + +} + +/** + * Handles users joining the connection, parsing their arguments and, for + * the connection owner, starting the client thread. + * + * @param user + * The user joining the connection. + * + * @param argc + * The number of arguments provided by the user. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * Zero if the user was successfully joined, non-zero otherwise. + */ +static int guac_dbshell_user_join_handler(guac_user* user, int argc, + char** argv) { + + guac_client* client = user->client; + guac_dbshell_client_internal* internal = + (guac_dbshell_client_internal*) client->data; + guac_dbshell_client* dbshell_client = &internal->client; + const guac_dbshell_plugin* plugin = dbshell_client->plugin; + + /* Parse the common arguments */ + guac_dbshell_settings* settings = guac_dbshell_settings_parse(user, + plugin->args, argc, (const char**) argv, plugin->argc, + plugin->default_port); + + /* Fail if settings cannot be parsed */ + if (settings == NULL) { + guac_user_log(user, GUAC_LOG_INFO, + "Badly formatted client arguments."); + return 1; + } + + /* Parse any database-specific arguments */ + void* extra_settings = NULL; + if (plugin->parse_extra_args != NULL) + extra_settings = plugin->parse_extra_args(user, argc, + (const char**) argv); + + /* Store settings at user level */ + guac_dbshell_user* user_data = guac_mem_zalloc(sizeof(guac_dbshell_user)); + user_data->settings = settings; + user_data->extra_settings = extra_settings; + user->data = user_data; + + /* Connect to the database if this user is the connection owner */ + if (user->owner) { + + /* Store owner's settings at client level */ + dbshell_client->settings = settings; + dbshell_client->extra_settings = extra_settings; + + /* Start client thread */ + if (pthread_create(&dbshell_client->client_thread, NULL, + guac_dbshell_client_thread, (void*) client)) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR, + "Unable to start database client thread"); + return 1; + } + + internal->thread_started = true; + + } + + /* Only handle events if not read-only */ + if (!settings->read_only) { + + /* General mouse/keyboard events */ + user->key_handler = guac_dbshell_user_key_handler; + user->mouse_handler = guac_dbshell_user_mouse_handler; + + /* Inbound (client to server) clipboard transfer */ + if (!settings->disable_paste) + user->clipboard_handler = guac_dbshell_clipboard_handler; + + /* STDIN redirection */ + user->pipe_handler = guac_dbshell_pipe_handler; + + /* Updates to connection parameters */ + user->argv_handler = guac_argv_handler; + + /* Display size change events */ + user->size_handler = guac_dbshell_user_size_handler; + + } + + return 0; + +} + +/** + * Handles users leaving the connection, removing them from the terminal + * and freeing their per-user data. + * + * @param user + * The user leaving the connection. + * + * @return + * Always zero. + */ +static int guac_dbshell_user_leave_handler(guac_user* user) { + + guac_dbshell_client* dbshell_client = + (guac_dbshell_client*) user->client->data; + + /* Remove the user from the terminal */ + if (dbshell_client->term != NULL) + guac_terminal_remove_user(dbshell_client->term, user); + + /* Free settings if not owner (owner settings are freed with the + * client) */ + guac_dbshell_user* user_data = (guac_dbshell_user*) user->data; + if (user_data != NULL && !user->owner) { + + guac_dbshell_settings_free(user_data->settings); + + if (dbshell_client->plugin->free_extra_args != NULL) + dbshell_client->plugin->free_extra_args( + user_data->extra_settings); + + } + + guac_mem_free(user_data); + return 0; + +} + +/** + * Synchronizes the terminal state and mid-session argument values to all + * pending users prior to their promotion to full users. + * + * @param client + * The client whose pending users are about to be promoted. + * + * @return + * Always zero. + */ +static int guac_dbshell_join_pending_handler(guac_client* client) { + + guac_dbshell_client* dbshell_client = (guac_dbshell_client*) client->data; + + /* Synchronize the terminal state to all pending users */ + if (dbshell_client->term != NULL) { + guac_socket* broadcast_socket = client->pending_socket; + guac_terminal_sync_users(dbshell_client->term, client, + broadcast_socket); + guac_dbshell_send_current_argv_batch(client, broadcast_socket); + guac_socket_flush(broadcast_socket); + } + + return 0; + +} + +/** + * Handles the disconnection and cleanup of the database client, stopping + * the client thread and freeing all associated resources. + * + * @param client + * The client being freed. + * + * @return + * Always zero. + */ +static int guac_dbshell_client_free_handler(guac_client* client) { + + guac_dbshell_client_internal* internal = + (guac_dbshell_client_internal*) client->data; + guac_dbshell_client* dbshell_client = &internal->client; + + /* Interrupt any statement currently blocking the client thread */ + if (dbshell_client->session != NULL) + guac_dbshell_session_cancel(dbshell_client->session); + + /* Stop the terminal to unblock any pending reads/writes */ + if (dbshell_client->term != NULL) + guac_terminal_stop(dbshell_client->term); + + /* Wait for the client thread to terminate */ + if (internal->thread_started) + pthread_join(dbshell_client->client_thread, NULL); + + /* Free the session, terminal, and recording now that the thread has + * finished */ + guac_dbshell_session_free(dbshell_client->session); + + if (dbshell_client->term != NULL) + guac_terminal_free(dbshell_client->term); + + if (dbshell_client->recording != NULL) + guac_recording_free(dbshell_client->recording); + + /* Free the owner's settings */ + guac_dbshell_settings_free(dbshell_client->settings); + + if (dbshell_client->plugin->free_extra_args != NULL) + dbshell_client->plugin->free_extra_args( + dbshell_client->extra_settings); + + guac_mem_free(internal); + return 0; + +} + +int guac_dbshell_client_init(guac_client* client, + const guac_dbshell_plugin* plugin) { + + /* Set client args */ + client->args = plugin->args; + + /* Allocate client instance data */ + guac_dbshell_client_internal* internal = + guac_mem_zalloc(sizeof(guac_dbshell_client_internal)); + internal->client.plugin = plugin; + client->data = internal; + + /* Set handlers */ + client->join_handler = guac_dbshell_user_join_handler; + client->join_pending_handler = guac_dbshell_join_pending_handler; + client->free_handler = guac_dbshell_client_free_handler; + client->leave_handler = guac_dbshell_user_leave_handler; + + /* Register handlers for argument values that may be sent after the + * handshake */ + guac_argv_register(GUAC_DBSHELL_ARGV_COLOR_SCHEME, + guac_dbshell_argv_callback, NULL, GUAC_ARGV_OPTION_ECHO); + guac_argv_register(GUAC_DBSHELL_ARGV_FONT_NAME, + guac_dbshell_argv_callback, NULL, GUAC_ARGV_OPTION_ECHO); + guac_argv_register(GUAC_DBSHELL_ARGV_FONT_SIZE, + guac_dbshell_argv_callback, NULL, GUAC_ARGV_OPTION_ECHO); + + /* Set locale and warn if not UTF-8 */ + setlocale(LC_CTYPE, ""); + if (strcmp(nl_langinfo(CODESET), "UTF-8") != 0) { + guac_client_log(client, GUAC_LOG_INFO, + "Current locale does not use UTF-8. Some characters may " + "not render correctly."); + } + + /* Success */ + return 0; + +} + +void guac_dbshell_prompt_credentials(guac_dbshell_session* session, + bool need_username, bool need_password) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + + /* Prompt for username if required but not provided */ + if (need_username && settings->username == NULL) + settings->username = guac_terminal_prompt(session->term, + "Username: ", true); + + /* Prompt for password if required but not provided, without echo */ + if (need_password && settings->password == NULL) + settings->password = guac_terminal_prompt(session->term, + "Password: ", false); + +} diff --git a/src/dbshell/dbshell/buffer.h b/src/dbshell/dbshell/buffer.h new file mode 100644 index 000000000..25380dae0 --- /dev/null +++ b/src/dbshell/dbshell/buffer.h @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_BUFFER_H +#define GUAC_DBSHELL_BUFFER_H + +/** + * Declarations for the dynamically-sized byte buffer used throughout the + * dbshell library to accumulate terminal output before writing it in a + * single call. + * + * @file buffer.h + */ + +/** + * A dynamically-sized byte buffer. + */ +typedef struct guac_dbshell_buffer { + + /** + * The accumulated bytes. This pointer is NULL until the first append. + */ + char* data; + + /** + * The number of accumulated bytes. + */ + int length; + + /** + * The number of bytes currently allocated. + */ + int allocated; + +} guac_dbshell_buffer; + +/** + * Initializes the given buffer to an empty state. + * + * @param buffer + * The buffer to initialize. + */ +void guac_dbshell_buffer_init(guac_dbshell_buffer* buffer); + +/** + * Releases the storage of the given buffer. + * + * @param buffer + * The buffer to release. + */ +void guac_dbshell_buffer_destroy(guac_dbshell_buffer* buffer); + +/** + * Appends the given bytes to the given buffer, expanding its storage as + * necessary. + * + * @param buffer + * The buffer to append to. + * + * @param bytes + * The bytes to append. + * + * @param length + * The number of bytes to append. + */ +void guac_dbshell_buffer_append(guac_dbshell_buffer* buffer, + const char* bytes, int length); + +/** + * Appends the given null-terminated string to the given buffer. + * + * @param buffer + * The buffer to append to. + * + * @param string + * The null-terminated string to append. + */ +void guac_dbshell_buffer_append_string(guac_dbshell_buffer* buffer, + const char* string); + +/** + * Appends the given character to the given buffer the given number of + * times. + * + * @param buffer + * The buffer to append to. + * + * @param c + * The character to append. + * + * @param count + * The number of copies of the character to append. Values less than + * one result in no change. + */ +void guac_dbshell_buffer_append_repeat(guac_dbshell_buffer* buffer, char c, + int count); + +/** + * Appends the given printf-style formatted text to the given buffer. The + * formatted text must not exceed 63 bytes; this function is intended only + * for short terminal control sequences and numbers. + * + * @param buffer + * The buffer to append to. + * + * @param format + * The printf-style format string. + * + * @param ... + * Any arguments to use when filling the format string. + */ +void guac_dbshell_buffer_appendf(guac_dbshell_buffer* buffer, + const char* format, ...); + +#endif diff --git a/src/dbshell/dbshell/client.h b/src/dbshell/dbshell/client.h new file mode 100644 index 000000000..f88727523 --- /dev/null +++ b/src/dbshell/dbshell/client.h @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_CLIENT_H +#define GUAC_DBSHELL_CLIENT_H + +/** + * Declarations for the generic protocol plugin scaffolding shared by all + * database protocol plugins. A plugin's guac_client_init() implementation + * needs only to invoke guac_dbshell_client_init() with its plugin + * definition; all user, input, clipboard, pipe, and lifecycle handling is + * provided here. + * + * @file client.h + */ + +#include "dbshell.h" +#include "driver.h" +#include "settings.h" + +#include +#include +#include +#include + +#include + +/** + * The name of the inbound pipe stream which, when opened by a user, + * redirects the terminal's STDIN to that stream. + */ +#define GUAC_DBSHELL_STDIN_PIPE_NAME "STDIN" + +/** + * The definition of a database protocol plugin: the driver implementing + * database-specific behavior, together with the plugin's argument list and + * defaults. + */ +typedef struct guac_dbshell_plugin { + + /** + * The driver implementing database-specific behavior. + */ + const guac_dbshell_driver* driver; + + /** + * The NULL-terminated argument list of the plugin. The list must begin + * with the arguments declared by GUAC_DBSHELL_COMMON_ARGS, optionally + * followed by database-specific arguments. + */ + const char** args; + + /** + * The number of arguments within the argument list, excluding the + * NULL terminator. + */ + int argc; + + /** + * The TCP port to use if no port argument is provided. + */ + int default_port; + + /** + * Parses the database-specific arguments of the plugin (the arguments + * at indices GUAC_DBSHELL_COMMON_ARG_COUNT and beyond), returning a + * newly-allocated, driver-defined settings structure. If the plugin + * has no database-specific arguments, this member may be NULL. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * A newly-allocated, driver-defined settings structure, or NULL if + * the plugin allocates none. + */ + void* (*parse_extra_args)(guac_user* user, int argc, const char** argv); + + /** + * Frees the driver-defined settings structure returned by + * parse_extra_args(). If parse_extra_args is NULL, this member may + * also be NULL. + * + * @param extra + * The driver-defined settings structure to free. + */ + void (*free_extra_args)(void* extra); + +} guac_dbshell_plugin; + +/** + * The data associated with a database protocol guac_client, assigned to + * that client's data member. + */ +typedef struct guac_dbshell_client { + + /** + * The plugin definition of the protocol. + */ + const guac_dbshell_plugin* plugin; + + /** + * The common settings of the connection owner, or NULL if the owner + * has not yet joined. + */ + guac_dbshell_settings* settings; + + /** + * The driver-defined settings of the connection owner, or NULL if the + * plugin defines none. + */ + void* extra_settings; + + /** + * The terminal which serves as the display and input source of the + * session, or NULL if the terminal has not yet been created. + */ + guac_terminal* term; + + /** + * The in-progress session recording, or NULL if no recording is in + * progress. + */ + guac_recording* recording; + + /** + * The database session, or NULL if the session has not yet been + * created. + */ + guac_dbshell_session* session; + + /** + * The thread which runs the database session. + */ + pthread_t client_thread; + +} guac_dbshell_client; + +/** + * The per-user data of a database protocol connection, assigned to each + * guac_user's data member upon join. + */ +typedef struct guac_dbshell_user { + + /** + * The common settings parsed from the arguments the user provided. + */ + guac_dbshell_settings* settings; + + /** + * The driver-defined settings parsed from the arguments the user + * provided, or NULL if the plugin defines none. + */ + void* extra_settings; + +} guac_dbshell_user; + +/** + * Initializes the given guac_client as a database protocol client using + * the given plugin definition. This function provides the complete + * implementation of a database protocol plugin's guac_client_init(). + * + * @param client + * The guac_client to initialize. + * + * @param plugin + * The plugin definition. This structure must have static storage + * duration. + * + * @return + * Zero on success, non-zero on failure. + */ +int guac_dbshell_client_init(guac_client* client, + const guac_dbshell_plugin* plugin); + +/** + * Prompts the user for their username and/or password using the terminal + * of the given session, storing the entered values within the session's + * common settings. Values which are already present within the settings + * are not prompted for. Password input is not echoed. + * + * @param session + * The session whose settings should be completed. + * + * @param need_username + * Whether a username is required for the database being connected to. + * + * @param need_password + * Whether a password is required for the database being connected to. + */ +void guac_dbshell_prompt_credentials(guac_dbshell_session* session, + bool need_username, bool need_password); + +#endif diff --git a/src/dbshell/dbshell/dbshell.h b/src/dbshell/dbshell/dbshell.h new file mode 100644 index 000000000..5eac774da --- /dev/null +++ b/src/dbshell/dbshell/dbshell.h @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_H +#define GUAC_DBSHELL_H + +/** + * Core declarations for the database shell (dbshell) library, a shared + * implementation of an interactive, terminal-based REPL (read-eval-print + * loop) for database protocol plugins. The dbshell library provides all + * database-agnostic functionality (line editing, command history, statement + * splitting, result table rendering, and the REPL itself), while each + * database protocol plugin provides a driver implementation which performs + * the actual communication with the database server. + * + * @file dbshell.h + */ + +#include "driver.h" +#include "history.h" + +#include +#include + +#include +#include + +/** + * The maximum number of statements retained within the in-memory command + * history of a database session. Older statements are discarded as new + * statements are submitted. History is never persisted. + */ +#define GUAC_DBSHELL_HISTORY_SIZE 100 + +/** + * The maximum length of the prompt string displayed at the beginning of each + * input line, in bytes, including the null terminator. + */ +#define GUAC_DBSHELL_MAX_PROMPT_LENGTH 64 + +/** + * The state of an interactive database session, shared between the REPL, the + * driver implementation of the database protocol plugin using that REPL, and + * the input handlers of that plugin. + */ +typedef struct guac_dbshell_session { + + /** + * The guac_client associated with the database session, for logging and + * lifecycle purposes. + */ + guac_client* client; + + /** + * The terminal emulator which serves as the display and input source of + * the database session. + */ + guac_terminal* term; + + /** + * The driver implementing database-specific behavior for this session. + */ + const guac_dbshell_driver* driver; + + /** + * Arbitrary data assigned by the driver, typically the connection handle + * of the underlying database client library. This value is assigned by + * the driver's connect handler and must be freed/released by the + * driver's disconnect handler. + */ + void* driver_data; + + /** + * The settings structure of the protocol plugin which created this + * session. The dbshell library does not interpret this value; it exists + * solely for the benefit of the driver. + */ + void* settings; + + /** + * The in-memory history of previously-submitted statements. + */ + guac_dbshell_history* history; + + /** + * Lock which protects the executing flag, guaranteeing that + * cancellation requests observe a consistent view of whether a + * statement is currently being executed. + */ + pthread_mutex_t execute_lock; + + /** + * Whether the driver's execute handler is currently running. This flag + * is set by the REPL around each execute call and tested by + * guac_dbshell_session_cancel() to determine whether a cancellation + * request should be forwarded to the driver. + */ + bool executing; + +} guac_dbshell_session; + +/** + * Allocates a new database session which will run within the given terminal + * on behalf of the given client, using the given driver. The returned + * session must eventually be freed with guac_dbshell_session_free(). + * + * @param client + * The guac_client associated with the database session. + * + * @param term + * The terminal emulator which should serve as the display and input + * source of the database session. + * + * @param driver + * The driver implementing database-specific behavior for the session. + * + * @param settings + * The settings structure of the protocol plugin creating the session, + * to be interpreted only by the driver. + * + * @return + * A newly-allocated database session, or NULL if the session could not + * be allocated. + */ +guac_dbshell_session* guac_dbshell_session_alloc(guac_client* client, + guac_terminal* term, const guac_dbshell_driver* driver, + void* settings); + +/** + * Frees all resources associated with the given database session. The + * driver's disconnect handler is NOT invoked by this function; callers must + * disconnect first if a connection was established. + * + * @param session + * The session to free. + */ +void guac_dbshell_session_free(guac_dbshell_session* session); + +/** + * Requests cancellation of the statement currently being executed by the + * given session, if any. This function is safe to call from threads other + * than the thread running the REPL, and does nothing if no statement is + * currently being executed or if the driver does not support cancellation. + * + * @param session + * The session whose current statement should be cancelled. + */ +void guac_dbshell_session_cancel(guac_dbshell_session* session); + +/** + * Runs the interactive REPL of the given session until the user exits, the + * terminal is stopped, or the connection to the database server is lost. + * The driver's connect handler must have completed successfully before this + * function is invoked. This function must be called from the protocol + * plugin's client thread. + * + * @param session + * The session whose REPL should run. + * + * @return + * Zero if the REPL terminated normally (user exit or terminal stop), or + * non-zero if the REPL terminated because the connection to the database + * server was lost. + */ +int guac_dbshell_repl_run(guac_dbshell_session* session); + +/** + * Writes the given printf-style message to the session's terminal, followed + * by a CRLF line ending. This is a convenience wrapper for drivers rendering + * errors and informational messages. + * + * @param session + * The session whose terminal should receive the message. + * + * @param format + * A printf-style format string. + * + * @param ... + * Any arguments to use when filling the format string. + */ +void guac_dbshell_println(guac_dbshell_session* session, + const char* format, ...); + +/** + * Writes a standard summary line to the session's terminal describing the + * outcome of a statement, in the style of common database CLIs, for example + * "3 rows in set (0.02 sec)" or "1 row affected (0.01 sec)". + * + * @param session + * The session whose terminal should receive the summary. + * + * @param rows + * The number of rows returned or affected by the statement. + * + * @param affected + * Whether the given row count refers to rows affected by the statement + * (non-zero) rather than rows returned in a result set (zero). + * + * @param millis + * The wall-clock duration of the statement, in milliseconds. + */ +void guac_dbshell_print_summary(guac_dbshell_session* session, + unsigned long rows, int affected, long millis); + +#endif diff --git a/src/dbshell/dbshell/driver.h b/src/dbshell/dbshell/driver.h new file mode 100644 index 000000000..38ad98f54 --- /dev/null +++ b/src/dbshell/dbshell/driver.h @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_DRIVER_H +#define GUAC_DBSHELL_DRIVER_H + +/** + * Declarations for the driver interface which database protocol plugins + * implement to provide database-specific behavior to the shared dbshell + * REPL. + * + * @file driver.h + */ + +#include "splitter.h" + +typedef struct guac_dbshell_session guac_dbshell_session; + +/** + * Return value of a driver's execute handler indicating that the statement + * completed, successfully or not, and the session may continue. + */ +#define GUAC_DBSHELL_EXECUTE_OK 0 + +/** + * Return value of a driver's execute handler indicating that the connection + * to the database server has been lost and the session must end. + */ +#define GUAC_DBSHELL_EXECUTE_LOST 1 + +/** + * The set of handlers and properties which define database-specific behavior + * of an interactive database session. Each database protocol plugin defines + * exactly one static instance of this structure. + * + * All strings received by and returned from these handlers are UTF-8. + */ +typedef struct guac_dbshell_driver { + + /** + * The human-readable, lowercase name of the database protocol, as used + * within the session prompt and log messages, for example "mysql". + */ + const char* name; + + /** + * The statement-splitting dialect of the database, one of the + * GUAC_DBSHELL_DIALECT_* constants. + */ + guac_dbshell_dialect dialect; + + /** + * Establishes the connection to the database server described by the + * settings of the given session, assigning the resulting + * connection handle to the session's driver_data. On failure, this + * handler is responsible for rendering a human-readable error to the + * session's terminal (or aborting the client), and any partially- + * allocated resources must be released. + * + * @param session + * The session on whose behalf the connection is being established. + * + * @return + * Zero on success, non-zero on failure. + */ + int (*connect_handler)(guac_dbshell_session* session); + + /** + * Closes the connection to the database server and frees the connection + * handle stored within the session's driver_data. This handler is + * invoked exactly once for each successful invocation of the connect + * handler. + * + * @param session + * The session whose connection should be closed. + */ + void (*disconnect_handler)(guac_dbshell_session* session); + + /** + * Executes the given single statement against the database server, + * rendering all result sets, row counts, and errors to the session's + * terminal. Statement errors are NOT failures of this handler; the + * handler fails only if the connection itself is lost. + * + * @param session + * The session on whose behalf the statement is being executed. + * + * @param statement + * The statement to execute, stripped of its terminator. + * + * @return + * GUAC_DBSHELL_EXECUTE_OK if the session may continue, or + * GUAC_DBSHELL_EXECUTE_LOST if the connection to the database server + * has been lost. + */ + int (*execute_handler)(guac_dbshell_session* session, + const char* statement); + + /** + * Interrupts the statement currently being executed by the execute + * handler, causing it to return as soon as possible. This handler is + * invoked from a different thread than the thread running the execute + * handler and must be thread-safe with respect to it. If the underlying + * database client library provides no thread-safe cancellation + * mechanism, this member may be NULL, in which case cancellation + * requests are ignored. + * + * @param session + * The session whose current statement should be interrupted. + */ + void (*cancel_handler)(guac_dbshell_session* session); + + /** + * Handles the given driver-specific meta-command (a command beginning + * with a backslash which is interpreted by the shell itself rather than + * sent to the database server), if the driver recognizes it. The command + * is provided without its leading backslash. If the driver does not + * provide any additional meta-commands, this member may be NULL. + * + * @param session + * The session on whose behalf the meta-command is being handled. + * + * @param command + * The meta-command to handle, without its leading backslash, for + * example "use mydb". + * + * @return + * Non-zero if the meta-command was recognized and handled, zero if + * the command is not recognized by the driver. + */ + int (*meta_handler)(guac_dbshell_session* session, const char* command); + + /** + * Writes driver-specific lines to the session's terminal as part of the + * help text produced by the \h meta-command, if any. If the driver adds + * no meta-commands of its own, this member may be NULL. + * + * @param session + * The session whose terminal should receive the help text. + */ + void (*help_handler)(guac_dbshell_session* session); + +} guac_dbshell_driver; + +#endif diff --git a/src/dbshell/dbshell/history.h b/src/dbshell/dbshell/history.h new file mode 100644 index 000000000..f94643e79 --- /dev/null +++ b/src/dbshell/dbshell/history.h @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_HISTORY_H +#define GUAC_DBSHELL_HISTORY_H + +/** + * Declarations for the bounded, in-memory command history of the dbshell + * REPL. History entries are stored only in memory and are freed when the + * session ends; they are never written to disk. + * + * @file history.h + */ + +/** + * A bounded, in-memory ring of previously-submitted command lines, ordered + * from oldest to newest. Once the ring is full, adding a new entry discards + * the oldest entry. + */ +typedef struct guac_dbshell_history { + + /** + * The circular buffer of history entries. Each entry is a + * null-terminated, dynamically-allocated string, or NULL if the slot is + * unused. + */ + char** entries; + + /** + * The maximum number of entries which may be stored within the ring. + */ + int capacity; + + /** + * The number of entries currently stored within the ring. + */ + int length; + + /** + * The index within entries of the slot which will receive the next + * added entry. + */ + int next; + +} guac_dbshell_history; + +/** + * Allocates a new, empty history ring with the given capacity. The returned + * history must eventually be freed with guac_dbshell_history_free(). + * + * @param capacity + * The maximum number of entries the ring may hold. Must be positive. + * + * @return + * A newly-allocated, empty history ring. + */ +guac_dbshell_history* guac_dbshell_history_alloc(int capacity); + +/** + * Frees the given history ring and all entries stored within it. + * + * @param history + * The history ring to free. + */ +void guac_dbshell_history_free(guac_dbshell_history* history); + +/** + * Adds a copy of the given line to the given history ring as its newest + * entry, discarding the oldest entry if the ring is full. If the given line + * is empty or identical to the newest existing entry, the ring is not + * modified. + * + * @param history + * The history ring to add the line to. + * + * @param line + * The line to add. + */ +void guac_dbshell_history_add(guac_dbshell_history* history, + const char* line); + +/** + * Returns the history entry which is the given number of steps back from the + * position following the newest entry. An offset of 1 returns the newest + * entry, an offset of 2 the entry before it, and so on. + * + * @param history + * The history ring to retrieve the entry from. + * + * @param offset + * The number of steps back from the position following the newest + * entry. + * + * @return + * The requested history entry, or NULL if the offset does not denote a + * stored entry. + */ +const char* guac_dbshell_history_get(guac_dbshell_history* history, + int offset); + +#endif diff --git a/src/dbshell/dbshell/line-editor.h b/src/dbshell/dbshell/line-editor.h new file mode 100644 index 000000000..45fd8d432 --- /dev/null +++ b/src/dbshell/dbshell/line-editor.h @@ -0,0 +1,556 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_LINE_EDITOR_H +#define GUAC_DBSHELL_LINE_EDITOR_H + +/** + * Declarations for the line editor of the dbshell REPL. The line editor + * reads raw bytes typed into the terminal and provides interactive editing + * of a single logical input line (which may span multiple display rows), + * including cursor movement, history navigation, and safe handling of + * escape sequences. + * + * The editing buffer, input parser, and display layout computations are + * plain data structures and pure functions, independent of the terminal, + * so that they can be unit tested in isolation. + * + * @file line-editor.h + */ + +#include "history.h" + +#include + +#include + +/** + * The maximum number of bytes of a single UTF-8 encoded character. + */ +#define GUAC_DBSHELL_MAX_CHAR_LENGTH 4 + +/** + * The number of spaces inserted into the editing buffer when the Tab key is + * pressed. Literal tab characters are never stored, keeping display width + * computations exact. + */ +#define GUAC_DBSHELL_TAB_WIDTH 4 + +/** + * The overall outcome of reading one line of input from the terminal. + */ +typedef enum guac_dbshell_read_status { + + /** + * A line of input was read successfully. + */ + GUAC_DBSHELL_READ_LINE, + + /** + * The user cancelled the current input with Ctrl+C. + */ + GUAC_DBSHELL_READ_CANCELLED, + + /** + * Input has ended, either because the user pressed Ctrl+D on an empty + * line or because the terminal has been closed or stopped. + */ + GUAC_DBSHELL_READ_CLOSED + +} guac_dbshell_read_status; + +/** + * The distinct editing actions produced by the input parser from the raw + * byte stream of the terminal. + */ +typedef enum guac_dbshell_key { + + /** + * No complete action is available yet; more input bytes are required. + */ + GUAC_DBSHELL_KEY_NONE, + + /** + * A complete printable character is available within the parser's + * char_buffer. + */ + GUAC_DBSHELL_KEY_CHAR, + + /** + * The Enter key (carriage return or line feed). + */ + GUAC_DBSHELL_KEY_ENTER, + + /** + * The Backspace key. + */ + GUAC_DBSHELL_KEY_BACKSPACE, + + /** + * The Delete key (forward delete). + */ + GUAC_DBSHELL_KEY_DELETE, + + /** + * The left arrow key. + */ + GUAC_DBSHELL_KEY_LEFT, + + /** + * The right arrow key. + */ + GUAC_DBSHELL_KEY_RIGHT, + + /** + * The up arrow key (older history). + */ + GUAC_DBSHELL_KEY_UP, + + /** + * The down arrow key (newer history). + */ + GUAC_DBSHELL_KEY_DOWN, + + /** + * The Home key or Ctrl+A. + */ + GUAC_DBSHELL_KEY_HOME, + + /** + * The End key or Ctrl+E. + */ + GUAC_DBSHELL_KEY_END, + + /** + * Ctrl+C. + */ + GUAC_DBSHELL_KEY_INTERRUPT, + + /** + * Ctrl+D. + */ + GUAC_DBSHELL_KEY_EOF, + + /** + * Ctrl+L (clear screen and redraw the current line). + */ + GUAC_DBSHELL_KEY_CLEAR, + + /** + * Ctrl+U (discard everything before the cursor). + */ + GUAC_DBSHELL_KEY_KILL_LINE, + + /** + * Ctrl+K (discard everything at and after the cursor). + */ + GUAC_DBSHELL_KEY_KILL_TO_END, + + /** + * Ctrl+W (discard the word before the cursor). + */ + GUAC_DBSHELL_KEY_KILL_WORD, + + /** + * The Tab key. + */ + GUAC_DBSHELL_KEY_TAB, + + /** + * A key or escape sequence which the editor deliberately ignores. + */ + GUAC_DBSHELL_KEY_IGNORED + +} guac_dbshell_key; + +/** + * The internal states of the input parser. + */ +typedef enum guac_dbshell_parser_state { + + /** + * Not within any escape sequence or multi-byte character. + */ + GUAC_DBSHELL_PARSER_GROUND, + + /** + * An ESC byte has been read. + */ + GUAC_DBSHELL_PARSER_ESC, + + /** + * Within a CSI sequence ("ESC ["), reading parameter and intermediate + * bytes until the final byte. + */ + GUAC_DBSHELL_PARSER_CSI, + + /** + * Within an SS3 sequence ("ESC O"), reading the single final byte. + */ + GUAC_DBSHELL_PARSER_SS3, + + /** + * Within a multi-byte UTF-8 character, reading continuation bytes. + */ + GUAC_DBSHELL_PARSER_UTF8 + +} guac_dbshell_parser_state; + +/** + * The maximum number of parameter/intermediate bytes of a CSI sequence + * which the parser stores for interpretation. Longer sequences are still + * consumed correctly but are ignored. + */ +#define GUAC_DBSHELL_PARSER_CSI_LENGTH 16 + +/** + * A state machine translating the raw byte stream of the terminal into + * editing actions. Bytes are fed one at a time and each byte produces + * exactly one guac_dbshell_key value (frequently GUAC_DBSHELL_KEY_NONE). + */ +typedef struct guac_dbshell_parser { + + /** + * The current state of the parser. + */ + guac_dbshell_parser_state state; + + /** + * The bytes of the UTF-8 character currently being accumulated, valid + * when guac_dbshell_parser_feed() returns GUAC_DBSHELL_KEY_CHAR. + */ + char char_buffer[GUAC_DBSHELL_MAX_CHAR_LENGTH]; + + /** + * The number of bytes stored within char_buffer. + */ + int char_length; + + /** + * The number of UTF-8 continuation bytes still expected. + */ + int utf8_remaining; + + /** + * The parameter/intermediate bytes of the CSI sequence currently being + * read. + */ + char csi_buffer[GUAC_DBSHELL_PARSER_CSI_LENGTH]; + + /** + * The number of bytes stored within csi_buffer. + */ + int csi_length; + + /** + * Whether the previously-fed byte was a carriage return, used to + * silently consume the line feed of a CRLF pair. + */ + bool last_was_cr; + +} guac_dbshell_parser; + +/** + * Initializes the given parser to its ground state. + * + * @param parser + * The parser to initialize. + */ +void guac_dbshell_parser_init(guac_dbshell_parser* parser); + +/** + * Feeds a single input byte to the given parser, returning the editing + * action the byte completes, if any. + * + * @param parser + * The parser to feed the byte to. + * + * @param byte + * The input byte. + * + * @return + * The editing action completed by the byte, or GUAC_DBSHELL_KEY_NONE if + * the byte does not complete an action. + */ +guac_dbshell_key guac_dbshell_parser_feed(guac_dbshell_parser* parser, + char byte); + +/** + * The editing buffer of the line editor: a single logical line of UTF-8 + * text together with a cursor position. + */ +typedef struct guac_dbshell_line { + + /** + * The UTF-8 text of the line. This buffer is dynamically resized as + * needed and is null-terminated. + */ + char* buffer; + + /** + * The length of the text within the buffer, in bytes, excluding the + * null terminator. + */ + int length; + + /** + * The number of bytes currently allocated for the buffer. + */ + int allocated; + + /** + * The cursor position within the buffer, in bytes. The cursor always + * lies on a UTF-8 character boundary, between 0 and length inclusive. + */ + int cursor; + +} guac_dbshell_line; + +/** + * Initializes the given editing buffer to an empty line. The buffer must + * eventually be released with guac_dbshell_line_destroy(). + * + * @param line + * The editing buffer to initialize. + */ +void guac_dbshell_line_init(guac_dbshell_line* line); + +/** + * Releases the storage of the given editing buffer. + * + * @param line + * The editing buffer to release. + */ +void guac_dbshell_line_destroy(guac_dbshell_line* line); + +/** + * Inserts the given bytes at the cursor position of the given editing + * buffer, advancing the cursor past the inserted bytes. + * + * @param line + * The editing buffer to insert into. + * + * @param bytes + * The bytes to insert. + * + * @param length + * The number of bytes to insert. + */ +void guac_dbshell_line_insert(guac_dbshell_line* line, const char* bytes, + int length); + +/** + * Removes the UTF-8 character immediately before the cursor of the given + * editing buffer, if any, moving the cursor back accordingly. + * + * @param line + * The editing buffer to modify. + */ +void guac_dbshell_line_backspace(guac_dbshell_line* line); + +/** + * Removes the UTF-8 character at the cursor of the given editing buffer, + * if any. The cursor does not move. + * + * @param line + * The editing buffer to modify. + */ +void guac_dbshell_line_delete(guac_dbshell_line* line); + +/** + * Moves the cursor of the given editing buffer one UTF-8 character to the + * left, if possible. + * + * @param line + * The editing buffer whose cursor should move. + */ +void guac_dbshell_line_left(guac_dbshell_line* line); + +/** + * Moves the cursor of the given editing buffer one UTF-8 character to the + * right, if possible. + * + * @param line + * The editing buffer whose cursor should move. + */ +void guac_dbshell_line_right(guac_dbshell_line* line); + +/** + * Removes all text before the cursor of the given editing buffer, moving + * the cursor to the beginning of the line. + * + * @param line + * The editing buffer to modify. + */ +void guac_dbshell_line_kill_before(guac_dbshell_line* line); + +/** + * Removes all text at and after the cursor of the given editing buffer. + * + * @param line + * The editing buffer to modify. + */ +void guac_dbshell_line_kill_after(guac_dbshell_line* line); + +/** + * Removes the word immediately before the cursor of the given editing + * buffer, along with any whitespace between that word and the cursor. + * + * @param line + * The editing buffer to modify. + */ +void guac_dbshell_line_kill_word(guac_dbshell_line* line); + +/** + * Replaces the entire content of the given editing buffer with the given + * text, placing the cursor at the end of the line. + * + * @param line + * The editing buffer to modify. + * + * @param text + * The null-terminated text which should replace the current content. + */ +void guac_dbshell_line_set(guac_dbshell_line* line, const char* text); + +/** + * The display layout of a prompt and editing buffer within a terminal of a + * given width, produced by guac_dbshell_line_layout(). + */ +typedef struct guac_dbshell_layout { + + /** + * The total number of display rows occupied by the prompt and text, + * including the forced wrap row if the text ends exactly at the right + * margin. + */ + int rows; + + /** + * The display row containing the cursor, relative to the first row of + * the prompt (0-based). + */ + int cursor_row; + + /** + * The display column of the cursor within its row (0-based). + */ + int cursor_col; + + /** + * Whether the prompt and text end exactly at the right margin, in which + * case rendering must force a wrap to the following row to avoid + * ambiguity with the terminal's deferred wrapping behavior. + */ + bool forced_wrap; + +} guac_dbshell_layout; + +/** + * Returns the display width of the given UTF-8 string in terminal columns, + * decoding UTF-8 directly and treating characters of indeterminate width as + * occupying a single column. + * + * @param text + * The UTF-8 text to measure. + * + * @param length + * The number of bytes of the text to measure. + * + * @return + * The display width of the text, in columns. + */ +int guac_dbshell_display_width(const char* text, int length); + +/** + * Advances the given byte position past the single UTF-8 character + * beginning at that position, returning the display width of that + * character. + * + * @param text + * The UTF-8 text being traversed. + * + * @param length + * The number of bytes of the text. + * + * @param position + * The byte index of the character. On return, this will have advanced + * past the character. + * + * @return + * The display width of the character, in columns. + */ +int guac_dbshell_display_next(const char* text, int length, int* position); + +/** + * Computes the display layout of the given prompt and editing buffer within + * a terminal having the given number of columns. + * + * @param prompt + * The null-terminated prompt string displayed before the text. + * + * @param line + * The editing buffer being displayed. + * + * @param cols + * The width of the terminal, in columns. Values less than one are + * treated as one. + * + * @param layout + * The structure to populate with the computed layout. + */ +void guac_dbshell_line_layout(const char* prompt, + const guac_dbshell_line* line, int cols, + guac_dbshell_layout* layout); + +/** + * Reads one logical line of input from the given terminal, providing + * interactive editing and history navigation. The prompt is written to the + * terminal before input is read and is redrawn as editing requires. + * + * @param term + * The terminal to read from and render to. + * + * @param parser + * The input parser to use. The parser must be owned by the caller and + * reused across successive reads of the same input stream, such that + * multi-byte sequences (like CRLF pairs) spanning two reads are handled + * correctly. + * + * @param history + * The history ring used for up/down navigation, or NULL if history + * navigation should be disabled. Submitted lines are NOT automatically + * added to the ring. + * + * @param prompt + * The null-terminated prompt string to display. + * + * @param status + * The structure to populate with the overall outcome of the read. + * + * @return + * A newly-allocated string containing the line read, which must + * eventually be freed with a call to guac_mem_free(), or NULL if the + * status is not GUAC_DBSHELL_READ_LINE. + */ +char* guac_dbshell_line_editor_read(guac_terminal* term, + guac_dbshell_parser* parser, guac_dbshell_history* history, + const char* prompt, guac_dbshell_read_status* status); + +#endif diff --git a/src/dbshell/dbshell/settings.h b/src/dbshell/dbshell/settings.h new file mode 100644 index 000000000..27bb6d4fc --- /dev/null +++ b/src/dbshell/dbshell/settings.h @@ -0,0 +1,392 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_SETTINGS_H +#define GUAC_DBSHELL_SETTINGS_H + +/** + * Declarations for the connection settings shared by all database protocol + * plugins. Each plugin's argument list begins with the common arguments + * declared here (via GUAC_DBSHELL_COMMON_ARGS), optionally followed by + * database-specific arguments parsed by the plugin itself. + * + * @file settings.h + */ + +#include + +#include + +/** + * The default number of seconds to wait for a connection to the database + * server to be established before giving up. + */ +#define GUAC_DBSHELL_DEFAULT_TIMEOUT 10 + +/** + * The filename to use for the typescript, if not specified. + */ +#define GUAC_DBSHELL_DEFAULT_TYPESCRIPT_NAME "typescript" + +/** + * The filename to use for the screen recording, if not specified. + */ +#define GUAC_DBSHELL_DEFAULT_RECORDING_NAME "recording" + +/** + * The name of the argument announcing the terminal color scheme, which may + * also be updated mid-session. + */ +#define GUAC_DBSHELL_ARGV_COLOR_SCHEME "color-scheme" + +/** + * The name of the argument announcing the terminal font name, which may + * also be updated mid-session. + */ +#define GUAC_DBSHELL_ARGV_FONT_NAME "font-name" + +/** + * The name of the argument announcing the terminal font size, which may + * also be updated mid-session. + */ +#define GUAC_DBSHELL_ARGV_FONT_SIZE "font-size" + +/** + * The number of arguments within GUAC_DBSHELL_COMMON_ARGS. Any + * database-specific arguments of a plugin begin at this index within the + * plugin's argument list. + */ +#define GUAC_DBSHELL_COMMON_ARG_COUNT 34 + +/** + * The connection arguments common to all database protocol plugins. Each + * plugin embeds this list at the beginning of its NULL-terminated argument + * array, appending any database-specific arguments afterwards. The + * corresponding values are parsed into a guac_dbshell_settings structure by + * guac_dbshell_settings_parse(). + */ +#define GUAC_DBSHELL_COMMON_ARGS \ + "hostname", \ + "port", \ + "username", \ + "password", \ + "database", \ + "timeout", \ + GUAC_DBSHELL_ARGV_FONT_NAME, \ + GUAC_DBSHELL_ARGV_FONT_SIZE, \ + GUAC_DBSHELL_ARGV_COLOR_SCHEME, \ + "typescript-path", \ + "typescript-name", \ + "create-typescript-path", \ + "typescript-write-existing", \ + "recording-path", \ + "recording-name", \ + "recording-exclude-output", \ + "recording-exclude-mouse", \ + "recording-include-keys", \ + "recording-include-clipboard", \ + "create-recording-path", \ + "recording-write-existing", \ + "read-only", \ + "backspace", \ + "scrollback", \ + "func-keys-and-keypad", \ + "clipboard-buffer-size", \ + "disable-copy", \ + "disable-paste", \ + "terminal-type", \ + "wol-send-packet", \ + "wol-mac-addr", \ + "wol-broadcast-addr", \ + "wol-udp-port", \ + "wol-wait-time" + +/** + * The settings common to all database protocol plugins, parsed from the + * leading GUAC_DBSHELL_COMMON_ARG_COUNT arguments given during the + * Guacamole protocol handshake. + */ +typedef struct guac_dbshell_settings { + + /** + * The hostname or IP address of the database server to connect to. + */ + char* hostname; + + /** + * The TCP port of the database server to connect to. + */ + int port; + + /** + * The username to authenticate as, or NULL if no username was + * provided. + */ + char* username; + + /** + * The password to authenticate with, or NULL if no password was + * provided. + */ + char* password; + + /** + * The name of the database to use initially, or NULL if no database + * was provided. + */ + char* database; + + /** + * The maximum number of seconds to wait for the connection to the + * database server to be established. + */ + int timeout; + + /** + * The name of the font to use for display rendering. + */ + char* font_name; + + /** + * The size of the font to use, in points. + */ + int font_size; + + /** + * The name of the color scheme to use. + */ + char* color_scheme; + + /** + * The path in which the typescript should be saved, if enabled. If no + * typescript should be saved, this will be NULL. + */ + char* typescript_path; + + /** + * The filename to use for the typescript, if enabled. + */ + char* typescript_name; + + /** + * Whether the typescript path should be automatically created if it + * does not already exist. + */ + bool create_typescript_path; + + /** + * Whether existing files should be appended to when creating a new + * typescript. Disabled by default. + */ + bool typescript_write_existing; + + /** + * The path in which the screen recording should be saved, if enabled. + * If no screen recording should be saved, this will be NULL. + */ + char* recording_path; + + /** + * The filename to use for the screen recording, if enabled. + */ + char* recording_name; + + /** + * Whether output which is broadcast to each connected client + * (graphics, streams, etc.) should NOT be included in the session + * recording. Output is included by default, as it is necessary for any + * recording which must later be viewable as video. + */ + bool recording_exclude_output; + + /** + * Whether changes to mouse state, such as position and buttons pressed + * or released, should NOT be included in the session recording. Mouse + * state is included by default, as it is necessary for the mouse + * cursor to be rendered in any resulting video. + */ + bool recording_exclude_mouse; + + /** + * Whether keys pressed and released should be included in the session + * recording. Key events are NOT included by default within the + * recording, as doing so has privacy and security implications. Key + * events can easily contain sensitive information, such as passwords. + */ + bool recording_include_keys; + + /** + * Whether clipboard data should be included in the session recording. + * Clipboard data is NOT included by default within the recording, as + * doing so has privacy and security implications. + */ + bool recording_include_clipboard; + + /** + * Whether the screen recording path should be automatically created if + * it does not already exist. + */ + bool create_recording_path; + + /** + * Whether existing files should be appended to when creating a new + * recording. Disabled by default. + */ + bool recording_write_existing; + + /** + * Whether this connection is read-only, and user input should be + * dropped. + */ + bool read_only; + + /** + * The ASCII code, as an integer, to send when the backspace key is + * pressed. + */ + int backspace; + + /** + * The maximum size of the scrollback buffer in rows. + */ + int max_scrollback; + + /** + * The family of codes (e.g. vt100) which will be used when the + * function and keypad keys are pressed. + */ + char* func_keys_and_keypad; + + /** + * The maximum number of bytes to allow within the clipboard. + */ + int clipboard_buffer_size; + + /** + * Whether outbound clipboard access should be blocked. If set, it will + * not be possible to copy data from the terminal to the client using + * the clipboard. + */ + bool disable_copy; + + /** + * Whether inbound clipboard access should be blocked. If set, it will + * not be possible to paste data from the client to the terminal using + * the clipboard. + */ + bool disable_paste; + + /** + * The terminal emulator type presented to the user (e.g. "xterm" or + * "linux"), determining the key encoding used. "linux" is used if + * unspecified. + */ + char* terminal_type; + + /** + * Whether a Wake-on-LAN packet should be sent to the database server + * prior to connecting. + */ + bool wol_send_packet; + + /** + * The MAC address to place within the Wake-on-LAN packet. + */ + char* wol_mac_addr; + + /** + * The broadcast address to which the Wake-on-LAN packet should be + * sent. + */ + char* wol_broadcast_addr; + + /** + * The UDP port to use when sending the Wake-on-LAN packet. + */ + unsigned short wol_udp_port; + + /** + * The number of seconds to wait after sending the Wake-on-LAN packet + * before attempting to connect to the database server. + */ + int wol_wait_time; + + /** + * The desired width of the terminal display, in pixels. + */ + int width; + + /** + * The desired height of the terminal display, in pixels. + */ + int height; + + /** + * The desired screen resolution, in DPI. + */ + int resolution; + +} guac_dbshell_settings; + +/** + * Parses the common (leading) connection arguments of a database protocol + * plugin into a newly-allocated settings structure. Any database-specific + * arguments which follow the common arguments must be parsed separately by + * the plugin. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param protocol_args + * The NULL-terminated argument list of the plugin, as assigned to + * guac_client's args member. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @param expected_argc + * The total number of arguments the plugin expects, including both the + * common arguments and any database-specific arguments. + * + * @param default_port + * The TCP port to use if no port argument is provided. + * + * @return + * A newly-allocated settings structure which must eventually be freed + * with guac_dbshell_settings_free(), or NULL if the arguments fail to + * parse. + */ +guac_dbshell_settings* guac_dbshell_settings_parse(guac_user* user, + const char** protocol_args, int argc, const char** argv, + int expected_argc, int default_port); + +/** + * Frees the given settings structure, having been previously allocated via + * guac_dbshell_settings_parse(). + * + * @param settings + * The settings structure to free. + */ +void guac_dbshell_settings_free(guac_dbshell_settings* settings); + +#endif diff --git a/src/dbshell/dbshell/splitter.h b/src/dbshell/dbshell/splitter.h new file mode 100644 index 000000000..d6a10d4fc --- /dev/null +++ b/src/dbshell/dbshell/splitter.h @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_SPLITTER_H +#define GUAC_DBSHELL_SPLITTER_H + +/** + * Declarations for the statement splitter of the dbshell REPL, which + * accumulates submitted input lines and determines where complete statements + * end according to the lexical rules of the database's query language + * (string literals, comments, statement terminators, etc.). + * + * @file splitter.h + */ + +#include + +/** + * The statement-splitting dialects understood by the splitter, determining + * which quoting styles, comment styles, and statement terminators apply. + */ +typedef enum guac_dbshell_dialect { + + /** + * MySQL-family SQL: single-quote, double-quote, and backtick quoting + * with backslash escapes, "-- " (with trailing space), "#", and block + * comments, statements terminated by semicolons. + */ + GUAC_DBSHELL_DIALECT_MYSQL, + + /** + * PostgreSQL SQL: single-quote, double-quote, and dollar-quoted string + * literals, "--" and nestable block comments, statements terminated by + * semicolons. + */ + GUAC_DBSHELL_DIALECT_PGSQL, + + /** + * Transact-SQL: single-quote, double-quote, and bracket quoting, "--" + * and block comments, statements terminated by semicolons or by a line + * consisting solely of the batch separator "GO". + */ + GUAC_DBSHELL_DIALECT_TSQL, + + /** + * Oracle SQL and PL/SQL: single-quote and double-quote literals, "--" + * and block comments. Plain SQL statements are terminated by + * semicolons, while PL/SQL blocks (statements beginning with DECLARE or + * BEGIN, and CREATE statements for procedural objects) are terminated + * only by a line consisting solely of a slash ("/"), as in SQL*Plus. + */ + GUAC_DBSHELL_DIALECT_ORACLE, + + /** + * A single JSON document: a statement is complete once at least one + * value has been read and all braces and brackets outside string + * literals are balanced. + */ + GUAC_DBSHELL_DIALECT_JSON + +} guac_dbshell_dialect; + +/** + * A statement splitter which accumulates input lines and produces complete + * statements according to a fixed dialect. The members of this structure + * are internal to the splitter implementation. + */ +typedef struct guac_dbshell_splitter guac_dbshell_splitter; + +/** + * Allocates a new, empty statement splitter for the given dialect. The + * returned splitter must eventually be freed with + * guac_dbshell_splitter_free(). + * + * @param dialect + * The dialect determining the lexical rules the splitter applies. + * + * @return + * A newly-allocated, empty statement splitter. + */ +guac_dbshell_splitter* guac_dbshell_splitter_alloc( + guac_dbshell_dialect dialect); + +/** + * Frees the given statement splitter and any input accumulated within it. + * + * @param splitter + * The splitter to free. + */ +void guac_dbshell_splitter_free(guac_dbshell_splitter* splitter); + +/** + * Appends the given input line to the input accumulated within the given + * splitter. A newline character is implicitly appended after the given + * line. Completed statements, if any, become available through + * guac_dbshell_splitter_next_statement(). + * + * @param splitter + * The splitter which should receive the line. + * + * @param line + * The null-terminated line of input to append, without any trailing + * newline character. + */ +void guac_dbshell_splitter_feed(guac_dbshell_splitter* splitter, + const char* line); + +/** + * Removes and returns the next complete statement from the input + * accumulated within the given splitter, if any. The returned statement has + * its statement terminator removed and leading/trailing whitespace trimmed. + * Statements which are empty after trimming are skipped. + * + * @param splitter + * The splitter to retrieve a statement from. + * + * @return + * A newly-allocated string containing the next complete statement, + * which must eventually be freed with a call to guac_mem_free(), or + * NULL if no complete statement is currently available. + */ +char* guac_dbshell_splitter_next_statement(guac_dbshell_splitter* splitter); + +/** + * Returns whether the given splitter currently holds accumulated input + * which does not yet form a complete statement, in which case the REPL + * should display a continuation prompt. + * + * @param splitter + * The splitter to test. + * + * @return + * True if the splitter holds an incomplete statement, false otherwise. + */ +bool guac_dbshell_splitter_pending(guac_dbshell_splitter* splitter); + +/** + * Discards all input accumulated within the given splitter, resetting it to + * its initial state. + * + * @param splitter + * The splitter to reset. + */ +void guac_dbshell_splitter_reset(guac_dbshell_splitter* splitter); + +/** + * Returns whether accumulated input has been discarded by the given + * splitter because it exceeded the maximum statement length, clearing the + * overflow indication in the process. + * + * @param splitter + * The splitter to test. + * + * @return + * True if accumulated input was discarded since the last time the + * overflow indication was cleared, false otherwise. + */ +bool guac_dbshell_splitter_overflowed(guac_dbshell_splitter* splitter); + +#endif diff --git a/src/dbshell/dbshell/table.h b/src/dbshell/dbshell/table.h new file mode 100644 index 000000000..3e68b7dd1 --- /dev/null +++ b/src/dbshell/dbshell/table.h @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_DBSHELL_TABLE_H +#define GUAC_DBSHELL_TABLE_H + +/** + * Declarations for the result table renderer of the dbshell REPL, which + * renders result sets as bordered ASCII tables in the style of common + * database CLIs. Because result sets may be arbitrarily large, an initial + * window of rows is buffered to compute column widths, after which rows are + * streamed directly to the terminal using the computed widths. + * + * All cell data is sanitized before rendering: control characters are + * replaced such that data received from the database server can never + * inject terminal escape sequences. + * + * @file table.h + */ + +#include + +#include + +/** + * The number of leading rows buffered in memory in order to compute column + * widths before any output is produced. Rows beyond this window are + * streamed using the widths computed from the buffered window. + */ +#define GUAC_DBSHELL_TABLE_SIZING_ROWS 1000 + +/** + * The maximum display width of a single table column, in terminal columns. + * Cell values wider than this are truncated, with the final column replaced + * by a truncation marker. + */ +#define GUAC_DBSHELL_TABLE_MAX_COL_WIDTH 128 + +/** + * A result table being rendered to a terminal. The members of this + * structure are internal to the table renderer implementation. + */ +typedef struct guac_dbshell_table guac_dbshell_table; + +/** + * Begins rendering of a result table having the given number of columns to + * the given terminal. The returned table must eventually be finished and + * freed with guac_dbshell_table_end(). + * + * @param term + * The terminal the table will be rendered to. + * + * @param num_columns + * The number of columns of the result set. Must be positive. + * + * @return + * A newly-allocated table, or NULL if the number of columns is not + * positive. + */ +guac_dbshell_table* guac_dbshell_table_begin(guac_terminal* term, + int num_columns); + +/** + * Assigns the header (column name) of the given column of the given table, + * along with whether the column holds numeric values and should thus be + * right-aligned. All headers must be assigned before the first row is + * added. + * + * @param table + * The table whose column header is being assigned. + * + * @param column + * The index of the column, where 0 is the leftmost column. + * + * @param name + * The name of the column. A copy is made; the caller retains ownership. + * If NULL, an empty name is used. + * + * @param numeric + * Whether values within the column are numeric and should be + * right-aligned. + */ +void guac_dbshell_table_set_header(guac_dbshell_table* table, int column, + const char* name, bool numeric); + +/** + * Adds a row of values to the given table. Values are copied and sanitized + * immediately; the caller retains ownership of the given array and strings. + * + * @param table + * The table to add the row to. + * + * @param values + * An array of exactly num_columns null-terminated UTF-8 strings, where + * a NULL element denotes the database NULL value. + */ +void guac_dbshell_table_add_row(guac_dbshell_table* table, + const char** values); + +/** + * Completes rendering of the given table, flushing all buffered rows and + * the closing border to the terminal, and frees the table. If no rows were + * added, only the header and borders are rendered. + * + * @param table + * The table to complete and free. + * + * @return + * The total number of rows which were rendered within the body of the + * table. + */ +unsigned long guac_dbshell_table_end(guac_dbshell_table* table); + +/** + * Copies the given UTF-8 cell value into a newly-allocated string, + * replacing each control character (all bytes below 0x20 and the DEL + * character) with a single space such that data from the database server + * cannot inject terminal control sequences or break table layout. The + * result must eventually be freed with guac_mem_free(). + * + * @param value + * The null-terminated value to sanitize. + * + * @return + * A newly-allocated, sanitized copy of the given value. + */ +char* guac_dbshell_table_sanitize(const char* value); + +#endif diff --git a/src/dbshell/history.c b/src/dbshell/history.c new file mode 100644 index 000000000..641fc4cde --- /dev/null +++ b/src/dbshell/history.c @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/history.h" + +#include +#include + +#include + +guac_dbshell_history* guac_dbshell_history_alloc(int capacity) { + + guac_dbshell_history* history = guac_mem_zalloc(sizeof(guac_dbshell_history)); + + history->entries = guac_mem_zalloc(guac_mem_ckd_mul_or_die(capacity, + sizeof(char*))); + history->capacity = capacity; + + return history; + +} + +void guac_dbshell_history_free(guac_dbshell_history* history) { + + /* Free each stored entry */ + for (int i = 0; i < history->capacity; i++) + guac_mem_free(history->entries[i]); + + guac_mem_free(history->entries); + guac_mem_free(history); + +} + +void guac_dbshell_history_add(guac_dbshell_history* history, + const char* line) { + + /* Never store empty lines */ + if (line == NULL || *line == '\0') + return; + + /* Skip consecutive duplicates */ + const char* newest = guac_dbshell_history_get(history, 1); + if (newest != NULL && strcmp(newest, line) == 0) + return; + + /* Replace the oldest entry (if any) with the new entry */ + guac_mem_free(history->entries[history->next]); + history->entries[history->next] = guac_strdup(line); + + /* Advance ring position */ + history->next = (history->next + 1) % history->capacity; + if (history->length < history->capacity) + history->length++; + +} + +const char* guac_dbshell_history_get(guac_dbshell_history* history, + int offset) { + + /* Only offsets denoting stored entries are valid */ + if (offset < 1 || offset > history->length) + return NULL; + + /* Walk backwards from the slot following the newest entry */ + int index = (history->next - offset + history->capacity) + % history->capacity; + + return history->entries[index]; + +} diff --git a/src/dbshell/line-editor.c b/src/dbshell/line-editor.c new file mode 100644 index 000000000..7c66bba0f --- /dev/null +++ b/src/dbshell/line-editor.c @@ -0,0 +1,1014 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/buffer.h" +#include "dbshell/history.h" +#include "dbshell/line-editor.h" + +#include +#include +#include + +#include +#include +#include +#include + +/** + * The initial number of bytes allocated for the editing buffer. + */ +#define GUAC_DBSHELL_LINE_INITIAL_SIZE 256 + +void guac_dbshell_parser_init(guac_dbshell_parser* parser) { + memset(parser, 0, sizeof(guac_dbshell_parser)); + parser->state = GUAC_DBSHELL_PARSER_GROUND; +} + +/** + * Interprets the final byte of a CSI sequence together with any + * accumulated parameter bytes, returning the corresponding editing action. + * + * @param parser + * The parser whose csi_buffer contains the accumulated parameter + * bytes. + * + * @param final + * The final byte of the CSI sequence. + * + * @return + * The editing action denoted by the sequence, or + * GUAC_DBSHELL_KEY_IGNORED if the sequence has no editing meaning. + */ +static guac_dbshell_key guac_dbshell_parser_csi(guac_dbshell_parser* parser, + char final) { + + switch (final) { + + /* Arrow keys */ + case 'A': return GUAC_DBSHELL_KEY_UP; + case 'B': return GUAC_DBSHELL_KEY_DOWN; + case 'C': return GUAC_DBSHELL_KEY_RIGHT; + case 'D': return GUAC_DBSHELL_KEY_LEFT; + + /* Home/End */ + case 'H': return GUAC_DBSHELL_KEY_HOME; + case 'F': return GUAC_DBSHELL_KEY_END; + + /* Sequences of the form "ESC [ n ~" */ + case '~': + + if (strcmp(parser->csi_buffer, "1") == 0 + || strcmp(parser->csi_buffer, "7") == 0) + return GUAC_DBSHELL_KEY_HOME; + + if (strcmp(parser->csi_buffer, "4") == 0 + || strcmp(parser->csi_buffer, "8") == 0) + return GUAC_DBSHELL_KEY_END; + + if (strcmp(parser->csi_buffer, "3") == 0) + return GUAC_DBSHELL_KEY_DELETE; + + return GUAC_DBSHELL_KEY_IGNORED; + + /* All other sequences are deliberately ignored */ + default: + return GUAC_DBSHELL_KEY_IGNORED; + + } + +} + +guac_dbshell_key guac_dbshell_parser_feed(guac_dbshell_parser* parser, + char byte) { + + unsigned char c = (unsigned char) byte; + + /* Track CR solely for absorbing the LF of CRLF pairs */ + bool last_was_cr = parser->last_was_cr; + parser->last_was_cr = false; + + switch (parser->state) { + + case GUAC_DBSHELL_PARSER_GROUND: + + /* Plain printable ASCII */ + if (c >= 0x20 && c != 0x7F && c < 0x80) { + parser->char_buffer[0] = byte; + parser->char_length = 1; + return GUAC_DBSHELL_KEY_CHAR; + } + + /* Leading byte of a multi-byte UTF-8 character */ + if (c >= 0xC2 && c <= 0xF4) { + + parser->char_buffer[0] = byte; + parser->char_length = 1; + + if (c >= 0xF0) + parser->utf8_remaining = 3; + else if (c >= 0xE0) + parser->utf8_remaining = 2; + else + parser->utf8_remaining = 1; + + parser->state = GUAC_DBSHELL_PARSER_UTF8; + return GUAC_DBSHELL_KEY_NONE; + + } + + /* Control characters */ + switch (c) { + + case 0x0D: /* CR */ + parser->last_was_cr = true; + return GUAC_DBSHELL_KEY_ENTER; + + case 0x0A: /* LF (absorbed if it completes a CRLF pair) */ + if (last_was_cr) + return GUAC_DBSHELL_KEY_NONE; + return GUAC_DBSHELL_KEY_ENTER; + + case 0x7F: /* DEL */ + case 0x08: /* BS */ + return GUAC_DBSHELL_KEY_BACKSPACE; + + case 0x1B: /* ESC */ + parser->state = GUAC_DBSHELL_PARSER_ESC; + return GUAC_DBSHELL_KEY_NONE; + + case 0x01: /* Ctrl+A */ + return GUAC_DBSHELL_KEY_HOME; + + case 0x05: /* Ctrl+E */ + return GUAC_DBSHELL_KEY_END; + + case 0x03: /* Ctrl+C */ + return GUAC_DBSHELL_KEY_INTERRUPT; + + case 0x04: /* Ctrl+D */ + return GUAC_DBSHELL_KEY_EOF; + + case 0x0C: /* Ctrl+L */ + return GUAC_DBSHELL_KEY_CLEAR; + + case 0x15: /* Ctrl+U */ + return GUAC_DBSHELL_KEY_KILL_LINE; + + case 0x0B: /* Ctrl+K */ + return GUAC_DBSHELL_KEY_KILL_TO_END; + + case 0x17: /* Ctrl+W */ + return GUAC_DBSHELL_KEY_KILL_WORD; + + case 0x09: /* Tab */ + return GUAC_DBSHELL_KEY_TAB; + + default: + return GUAC_DBSHELL_KEY_IGNORED; + + } + + case GUAC_DBSHELL_PARSER_ESC: + + if (byte == '[') { + parser->state = GUAC_DBSHELL_PARSER_CSI; + parser->csi_length = 0; + parser->csi_buffer[0] = '\0'; + return GUAC_DBSHELL_KEY_NONE; + } + + if (byte == 'O') { + parser->state = GUAC_DBSHELL_PARSER_SS3; + return GUAC_DBSHELL_KEY_NONE; + } + + /* Any other byte following ESC (Alt+key combinations, etc.) is + * consumed and ignored */ + parser->state = GUAC_DBSHELL_PARSER_GROUND; + return GUAC_DBSHELL_KEY_IGNORED; + + case GUAC_DBSHELL_PARSER_CSI: + + /* Parameter and intermediate bytes */ + if (c >= 0x20 && c <= 0x3F) { + + if (parser->csi_length < GUAC_DBSHELL_PARSER_CSI_LENGTH - 1) { + parser->csi_buffer[parser->csi_length++] = byte; + parser->csi_buffer[parser->csi_length] = '\0'; + } + + return GUAC_DBSHELL_KEY_NONE; + + } + + /* Final byte */ + parser->state = GUAC_DBSHELL_PARSER_GROUND; + if (c >= 0x40 && c <= 0x7E) + return guac_dbshell_parser_csi(parser, byte); + + /* Malformed sequence */ + return GUAC_DBSHELL_KEY_IGNORED; + + case GUAC_DBSHELL_PARSER_SS3: + + parser->state = GUAC_DBSHELL_PARSER_GROUND; + + switch (byte) { + case 'A': return GUAC_DBSHELL_KEY_UP; + case 'B': return GUAC_DBSHELL_KEY_DOWN; + case 'C': return GUAC_DBSHELL_KEY_RIGHT; + case 'D': return GUAC_DBSHELL_KEY_LEFT; + case 'H': return GUAC_DBSHELL_KEY_HOME; + case 'F': return GUAC_DBSHELL_KEY_END; + default: return GUAC_DBSHELL_KEY_IGNORED; + } + + case GUAC_DBSHELL_PARSER_UTF8: + + /* Valid continuation byte */ + if (c >= 0x80 && c <= 0xBF) { + + parser->char_buffer[parser->char_length++] = byte; + + if (--parser->utf8_remaining == 0) { + parser->state = GUAC_DBSHELL_PARSER_GROUND; + return GUAC_DBSHELL_KEY_CHAR; + } + + return GUAC_DBSHELL_KEY_NONE; + + } + + /* Invalid byte within UTF-8 character */ + parser->state = GUAC_DBSHELL_PARSER_GROUND; + return GUAC_DBSHELL_KEY_IGNORED; + + } + + return GUAC_DBSHELL_KEY_IGNORED; + +} + +void guac_dbshell_line_init(guac_dbshell_line* line) { + line->buffer = guac_mem_alloc(GUAC_DBSHELL_LINE_INITIAL_SIZE); + line->buffer[0] = '\0'; + line->length = 0; + line->allocated = GUAC_DBSHELL_LINE_INITIAL_SIZE; + line->cursor = 0; +} + +void guac_dbshell_line_destroy(guac_dbshell_line* line) { + guac_mem_free(line->buffer); + line->buffer = NULL; +} + +/** + * Returns the number of bytes of the UTF-8 character beginning at the given + * byte, as determined from its leading byte. + * + * @param c + * The leading byte of the character. + * + * @return + * The number of bytes of the character, between 1 and 4 inclusive. + */ +static int guac_dbshell_char_length(unsigned char c) { + + if (c >= 0xF0) + return 4; + + if (c >= 0xE0) + return 3; + + if (c >= 0xC0) + return 2; + + return 1; + +} + +/** + * Returns the byte index of the beginning of the UTF-8 character preceding + * the given position within the given buffer. + * + * @param buffer + * The buffer containing UTF-8 text. + * + * @param position + * The byte index to search backwards from. + * + * @return + * The byte index of the beginning of the preceding character, or zero + * if there is no preceding character. + */ +static int guac_dbshell_prev_char(const char* buffer, int position) { + + if (position <= 0) + return 0; + + /* Skip backwards over continuation bytes */ + position--; + while (position > 0 + && ((unsigned char) buffer[position] & 0xC0) == 0x80) + position--; + + return position; + +} + +void guac_dbshell_line_insert(guac_dbshell_line* line, const char* bytes, + int length) { + + if (length <= 0) + return; + + /* Expand buffer as necessary */ + int required = line->length + length + 1; + if (required > line->allocated) { + + int allocated = line->allocated; + while (allocated < required) + allocated = guac_mem_ckd_mul_or_die(allocated, 2); + + line->buffer = guac_mem_realloc(line->buffer, allocated); + line->allocated = allocated; + + } + + /* Shift text after the cursor and insert */ + memmove(line->buffer + line->cursor + length, + line->buffer + line->cursor, line->length - line->cursor); + memcpy(line->buffer + line->cursor, bytes, length); + + line->length += length; + line->cursor += length; + line->buffer[line->length] = '\0'; + +} + +void guac_dbshell_line_backspace(guac_dbshell_line* line) { + + if (line->cursor == 0) + return; + + int start = guac_dbshell_prev_char(line->buffer, line->cursor); + memmove(line->buffer + start, line->buffer + line->cursor, + line->length - line->cursor); + + line->length -= line->cursor - start; + line->cursor = start; + line->buffer[line->length] = '\0'; + +} + +void guac_dbshell_line_delete(guac_dbshell_line* line) { + + if (line->cursor >= line->length) + return; + + int char_length = guac_dbshell_char_length( + (unsigned char) line->buffer[line->cursor]); + + /* Never remove beyond the end of the buffer */ + if (line->cursor + char_length > line->length) + char_length = line->length - line->cursor; + + memmove(line->buffer + line->cursor, + line->buffer + line->cursor + char_length, + line->length - line->cursor - char_length); + + line->length -= char_length; + line->buffer[line->length] = '\0'; + +} + +void guac_dbshell_line_left(guac_dbshell_line* line) { + line->cursor = guac_dbshell_prev_char(line->buffer, line->cursor); +} + +void guac_dbshell_line_right(guac_dbshell_line* line) { + + if (line->cursor >= line->length) + return; + + int char_length = guac_dbshell_char_length( + (unsigned char) line->buffer[line->cursor]); + + line->cursor += char_length; + if (line->cursor > line->length) + line->cursor = line->length; + +} + +void guac_dbshell_line_kill_before(guac_dbshell_line* line) { + + memmove(line->buffer, line->buffer + line->cursor, + line->length - line->cursor); + + line->length -= line->cursor; + line->cursor = 0; + line->buffer[line->length] = '\0'; + +} + +void guac_dbshell_line_kill_after(guac_dbshell_line* line) { + line->length = line->cursor; + line->buffer[line->length] = '\0'; +} + +void guac_dbshell_line_kill_word(guac_dbshell_line* line) { + + int start = line->cursor; + + /* Skip whitespace immediately before the cursor */ + while (start > 0 + && isspace((unsigned char) line->buffer[start - 1])) + start--; + + /* Skip the word itself */ + while (start > 0 + && !isspace((unsigned char) line->buffer[start - 1])) + start--; + + memmove(line->buffer + start, line->buffer + line->cursor, + line->length - line->cursor); + + line->length -= line->cursor - start; + line->cursor = start; + line->buffer[line->length] = '\0'; + +} + +void guac_dbshell_line_set(guac_dbshell_line* line, const char* text) { + + line->length = 0; + line->cursor = 0; + line->buffer[0] = '\0'; + + guac_dbshell_line_insert(line, text, strlen(text)); + +} + +/** + * Decodes the UTF-8 character beginning at the given byte position, + * returning its codepoint and advancing the position past the character. + * Invalid bytes are consumed individually and returned as the replacement + * value of their raw byte. + * + * @param text + * The UTF-8 text to decode from. + * + * @param length + * The number of bytes of text. + * + * @param position + * The byte index of the character to decode. On return, this will have + * advanced past the decoded character. + * + * @return + * The decoded codepoint. + */ +static int guac_dbshell_decode_utf8(const char* text, int length, + int* position) { + + unsigned char c = (unsigned char) text[*position]; + int remaining; + int codepoint; + + if (c < 0x80) { + (*position)++; + return c; + } + + if (c >= 0xF0) { + codepoint = c & 0x07; + remaining = 3; + } + else if (c >= 0xE0) { + codepoint = c & 0x0F; + remaining = 2; + } + else if (c >= 0xC0) { + codepoint = c & 0x1F; + remaining = 1; + } + else { + /* Unexpected continuation byte */ + (*position)++; + return c; + } + + (*position)++; + while (remaining > 0 && *position < length) { + + unsigned char continuation = (unsigned char) text[*position]; + if ((continuation & 0xC0) != 0x80) + break; + + codepoint = (codepoint << 6) | (continuation & 0x3F); + (*position)++; + remaining--; + + } + + return codepoint; + +} + +/** + * Returns the display width of the given codepoint in terminal columns, + * treating codepoints of indeterminate width as occupying a single column. + * + * @param codepoint + * The codepoint to measure. + * + * @return + * The display width of the codepoint, in columns. + */ +static int guac_dbshell_codepoint_width(int codepoint) { + + int width = wcwidth((wchar_t) codepoint); + if (width < 0) + width = 1; + + return width; + +} + +int guac_dbshell_display_width(const char* text, int length) { + + int width = 0; + int position = 0; + + while (position < length) + width += guac_dbshell_codepoint_width( + guac_dbshell_decode_utf8(text, length, &position)); + + return width; + +} + +int guac_dbshell_display_next(const char* text, int length, int* position) { + return guac_dbshell_codepoint_width( + guac_dbshell_decode_utf8(text, length, position)); +} + +/** + * The display position reached after writing a sequence of characters, + * used while simulating terminal layout. + */ +typedef struct guac_dbshell_position { + + /** + * The display row, relative to the first row of the prompt (0-based). + */ + int row; + + /** + * The display column within the row (0-based). This may equal the + * terminal width when the last character written ended exactly at the + * right margin. + */ + int col; + +} guac_dbshell_position; + +/** + * Advances the given display position by a character of the given width, + * wrapping to the following row if the character would not fit within the + * current row. Characters wider than the terminal never fit and occupy a + * row by themselves. + * + * @param position + * The display position to advance. + * + * @param width + * The display width of the character, in columns. + * + * @param cols + * The width of the terminal, in columns. + */ +static void guac_dbshell_position_advance(guac_dbshell_position* position, + int width, int cols) { + + /* Wrap to the following row if the character does not fit */ + if (position->col + width > cols) { + position->row++; + position->col = 0; + } + + position->col += width; + +} + +void guac_dbshell_line_layout(const char* prompt, + const guac_dbshell_line* line, int cols, + guac_dbshell_layout* layout) { + + if (cols < 1) + cols = 1; + + guac_dbshell_position position = { 0, 0 }; + guac_dbshell_position cursor = { 0, 0 }; + + /* Lay out the prompt (assumed to contain no multi-byte characters) */ + int prompt_length = strlen(prompt); + for (int i = 0; i < prompt_length; i++) + guac_dbshell_position_advance(&position, 1, cols); + + /* Cursor cannot precede the text */ + cursor = position; + + /* Lay out the text, capturing the position at the cursor */ + int byte = 0; + while (byte < line->length) { + + if (byte == line->cursor) + cursor = position; + + int width = guac_dbshell_codepoint_width( + guac_dbshell_decode_utf8(line->buffer, line->length, &byte)); + + guac_dbshell_position_advance(&position, width, cols); + + } + + if (line->cursor >= line->length) + cursor = position; + + /* A position at the exact right margin is displayed at the beginning + * of the following row */ + layout->forced_wrap = (position.col >= cols); + if (position.col >= cols) { + position.row++; + position.col = 0; + } + + if (cursor.col >= cols) { + cursor.row++; + cursor.col = 0; + } + + layout->rows = position.row + 1; + layout->cursor_row = cursor.row; + layout->cursor_col = cursor.col; + +} + +/** + * The full state of an in-progress line read, tying together the editing + * buffer, the layout most recently rendered, and history navigation. + */ +typedef struct guac_dbshell_editor { + + /** + * The terminal being read from and rendered to. + */ + guac_terminal* term; + + /** + * The prompt displayed before the text. + */ + const char* prompt; + + /** + * The editing buffer. + */ + guac_dbshell_line line; + + /** + * The layout of the most recent redraw, used to locate and clear the + * previously-rendered rows. + */ + guac_dbshell_layout rendered; + + /** + * Whether a redraw has occurred and the rendered layout is valid. + */ + bool have_rendered; + + /** + * The history ring used for up/down navigation, or NULL if history + * navigation is disabled. + */ + guac_dbshell_history* history; + + /** + * The current offset within the history ring, where zero denotes the + * line being edited and one denotes the newest history entry. + */ + int history_offset; + + /** + * The content of the line being edited at the time history navigation + * began, restored when navigating back to offset zero, or NULL if no + * content is stashed. + */ + char* stash; + +} guac_dbshell_editor; + +/** + * Redraws the prompt and editing buffer of the given editor, clearing all + * previously-rendered rows and leaving the terminal cursor at the editing + * cursor position. + * + * @param editor + * The editor to redraw. + */ +static void guac_dbshell_editor_redraw(guac_dbshell_editor* editor) { + + guac_dbshell_buffer output; + guac_dbshell_buffer_init(&output); + + int cols = guac_terminal_get_columns(editor->term); + + /* Clear all previously-rendered rows, from the bottom row upwards */ + if (editor->have_rendered) { + + int below = editor->rendered.rows - 1 - editor->rendered.cursor_row; + if (below > 0) + guac_dbshell_buffer_appendf(&output, "\x1B[%iB", below); + + for (int i = editor->rendered.rows - 1; i > 0; i--) + guac_dbshell_buffer_append(&output, "\r\x1B[K\x1B[A", 7); + + } + + guac_dbshell_buffer_append(&output, "\r\x1B[K", 4); + + /* Render prompt and text */ + guac_dbshell_buffer_append_string(&output, editor->prompt); + guac_dbshell_buffer_append(&output, editor->line.buffer, + editor->line.length); + + /* Compute the new layout, forcing the final wrap if the text ends + * exactly at the right margin */ + guac_dbshell_layout layout; + guac_dbshell_line_layout(editor->prompt, &editor->line, cols, &layout); + + if (layout.forced_wrap) + guac_dbshell_buffer_append(&output, "\r\n", 2); + + /* Move from the end of the text to the cursor position */ + int up = (layout.rows - 1) - layout.cursor_row; + if (up > 0) + guac_dbshell_buffer_appendf(&output, "\x1B[%iA", up); + + guac_dbshell_buffer_append(&output, "\r", 1); + if (layout.cursor_col > 0) + guac_dbshell_buffer_appendf(&output, "\x1B[%iC", layout.cursor_col); + + guac_terminal_write(editor->term, output.data, output.length); + guac_dbshell_buffer_destroy(&output); + + editor->rendered = layout; + editor->have_rendered = true; + +} + +/** + * Moves the terminal cursor of the given editor from its current position + * to the row following the final row of the rendered text, in preparation + * for output which follows the completed line. + * + * @param editor + * The editor whose rendering is being finished. + */ +static void guac_dbshell_editor_finish(guac_dbshell_editor* editor) { + + guac_dbshell_buffer output; + guac_dbshell_buffer_init(&output); + + if (editor->have_rendered) { + int below = editor->rendered.rows - 1 - editor->rendered.cursor_row; + if (below > 0) + guac_dbshell_buffer_appendf(&output, "\x1B[%iB", below); + } + + guac_dbshell_buffer_append(&output, "\r\n", 2); + + guac_terminal_write(editor->term, output.data, output.length); + guac_dbshell_buffer_destroy(&output); + +} + +/** + * Replaces the content of the given editor's editing buffer according to + * the given step through history, stashing or restoring the in-progress + * line as appropriate. + * + * @param editor + * The editor navigating through history. + * + * @param direction + * The direction of navigation: positive to move to older entries, + * negative to move to newer entries. + */ +static void guac_dbshell_editor_history(guac_dbshell_editor* editor, + int direction) { + + if (editor->history == NULL) + return; + + int offset = editor->history_offset + direction; + + /* Clamp navigation to the available entries */ + if (offset < 0 || offset > editor->history->length) + return; + + /* Stash the in-progress line upon first navigating away from it */ + if (editor->history_offset == 0 && offset > 0) { + guac_mem_free(editor->stash); + editor->stash = guac_strdup(editor->line.buffer); + } + + if (offset == 0) { + + /* Restore the stashed in-progress line */ + guac_dbshell_line_set(&editor->line, + editor->stash != NULL ? editor->stash : ""); + + } + + else { + + const char* entry = guac_dbshell_history_get(editor->history, + offset); + if (entry == NULL) + return; + + guac_dbshell_line_set(&editor->line, entry); + + } + + editor->history_offset = offset; + +} + +char* guac_dbshell_line_editor_read(guac_terminal* term, + guac_dbshell_parser* parser, guac_dbshell_history* history, + const char* prompt, guac_dbshell_read_status* status) { + + guac_dbshell_editor editor = { + .term = term, + .prompt = prompt, + .history = history + }; + + guac_dbshell_line_init(&editor.line); + guac_dbshell_editor_redraw(&editor); + + char* result = NULL; + *status = GUAC_DBSHELL_READ_CLOSED; + + /* Spaces inserted in place of a literal tab character */ + static const char tab_spaces[] = " "; + + char byte; + bool done = false; + + while (!done && guac_terminal_read_stdin(term, &byte, 1) == 1) { + + bool modified = false; + + switch (guac_dbshell_parser_feed(parser, byte)) { + + case GUAC_DBSHELL_KEY_CHAR: + guac_dbshell_line_insert(&editor.line, parser->char_buffer, + parser->char_length); + modified = true; + break; + + case GUAC_DBSHELL_KEY_TAB: + guac_dbshell_line_insert(&editor.line, tab_spaces, + sizeof(tab_spaces) - 1); + modified = true; + break; + + case GUAC_DBSHELL_KEY_ENTER: + guac_dbshell_editor_finish(&editor); + result = guac_strdup(editor.line.buffer); + *status = GUAC_DBSHELL_READ_LINE; + done = true; + break; + + case GUAC_DBSHELL_KEY_BACKSPACE: + guac_dbshell_line_backspace(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_DELETE: + guac_dbshell_line_delete(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_LEFT: + guac_dbshell_line_left(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_RIGHT: + guac_dbshell_line_right(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_UP: + guac_dbshell_editor_history(&editor, 1); + modified = true; + break; + + case GUAC_DBSHELL_KEY_DOWN: + guac_dbshell_editor_history(&editor, -1); + modified = true; + break; + + case GUAC_DBSHELL_KEY_HOME: + editor.line.cursor = 0; + modified = true; + break; + + case GUAC_DBSHELL_KEY_END: + editor.line.cursor = editor.line.length; + modified = true; + break; + + case GUAC_DBSHELL_KEY_INTERRUPT: + guac_dbshell_editor_finish(&editor); + guac_terminal_printf(term, "^C\r\n"); + *status = GUAC_DBSHELL_READ_CANCELLED; + done = true; + break; + + case GUAC_DBSHELL_KEY_EOF: + + /* Ctrl+D on an empty line ends input; on a non-empty line + * it deletes forward, as in readline */ + if (editor.line.length == 0) { + guac_dbshell_editor_finish(&editor); + *status = GUAC_DBSHELL_READ_CLOSED; + done = true; + } + else { + guac_dbshell_line_delete(&editor.line); + modified = true; + } + + break; + + case GUAC_DBSHELL_KEY_CLEAR: + guac_terminal_write(term, "\x1B[H\x1B[2J", 7); + editor.have_rendered = false; + modified = true; + break; + + case GUAC_DBSHELL_KEY_KILL_LINE: + guac_dbshell_line_kill_before(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_KILL_TO_END: + guac_dbshell_line_kill_after(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_KILL_WORD: + guac_dbshell_line_kill_word(&editor.line); + modified = true; + break; + + case GUAC_DBSHELL_KEY_NONE: + case GUAC_DBSHELL_KEY_IGNORED: + break; + + } + + if (modified) + guac_dbshell_editor_redraw(&editor); + + } + + guac_dbshell_line_destroy(&editor.line); + guac_mem_free(editor.stash); + + return result; + +} diff --git a/src/dbshell/repl.c b/src/dbshell/repl.c new file mode 100644 index 000000000..c3365aa3a --- /dev/null +++ b/src/dbshell/repl.c @@ -0,0 +1,348 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/dbshell.h" +#include "dbshell/driver.h" +#include "dbshell/history.h" +#include "dbshell/line-editor.h" +#include "dbshell/splitter.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +guac_dbshell_session* guac_dbshell_session_alloc(guac_client* client, + guac_terminal* term, const guac_dbshell_driver* driver, + void* settings) { + + guac_dbshell_session* session = + guac_mem_zalloc(sizeof(guac_dbshell_session)); + + session->client = client; + session->term = term; + session->driver = driver; + session->settings = settings; + session->history = + guac_dbshell_history_alloc(GUAC_DBSHELL_HISTORY_SIZE); + + pthread_mutex_init(&session->execute_lock, NULL); + + return session; + +} + +void guac_dbshell_session_free(guac_dbshell_session* session) { + + if (session == NULL) + return; + + guac_dbshell_history_free(session->history); + pthread_mutex_destroy(&session->execute_lock); + guac_mem_free(session); + +} + +void guac_dbshell_session_cancel(guac_dbshell_session* session) { + + pthread_mutex_lock(&session->execute_lock); + + /* Forward the request only if a statement is genuinely executing and + * the driver supports cancellation */ + if (session->executing && session->driver->cancel_handler != NULL) + session->driver->cancel_handler(session); + + pthread_mutex_unlock(&session->execute_lock); + +} + +void guac_dbshell_println(guac_dbshell_session* session, + const char* format, ...) { + + va_list args; + + /* Measure the message */ + va_start(args, format); + int length = vsnprintf(NULL, 0, format, args); + va_end(args); + + if (length < 0) + return; + + /* Format the message */ + char* message = guac_mem_alloc(guac_mem_ckd_add_or_die(length, 1)); + va_start(args, format); + vsnprintf(message, length + 1, format, args); + va_end(args); + + guac_terminal_write(session->term, message, length); + guac_terminal_write(session->term, "\r\n", 2); + + guac_mem_free(message); + +} + +void guac_dbshell_print_summary(guac_dbshell_session* session, + unsigned long rows, int affected, long millis) { + + guac_dbshell_println(session, "%lu %s %s (%li.%02li sec)", + rows, + rows == 1 ? "row" : "rows", + affected ? "affected" : "in set", + millis / 1000, (millis % 1000) / 10); + +} + +/** + * Returns whether the given input line consists solely of the given word, + * compared case-insensitively, optionally followed by a single semicolon. + * Leading and trailing whitespace is ignored. + * + * @param line + * The null-terminated input line to test. + * + * @param word + * The null-terminated word to compare the line against. + * + * @return + * Non-zero if the line consists solely of the given word, zero + * otherwise. + */ +static int guac_dbshell_line_is_command(const char* line, + const char* word) { + + /* Ignore leading whitespace */ + while (isspace((unsigned char) *line)) + line++; + + /* Compare against the word itself */ + int word_length = strlen(word); + for (int i = 0; i < word_length; i++) { + if (tolower((unsigned char) line[i]) + != tolower((unsigned char) word[i])) + return 0; + } + + line += word_length; + + /* Allow a single trailing semicolon */ + if (*line == ';') + line++; + + /* Ignore trailing whitespace */ + while (isspace((unsigned char) *line)) + line++; + + return *line == '\0'; + +} + +/** + * Writes the built-in help text to the terminal of the given session, + * followed by any driver-specific help lines. + * + * @param session + * The session requesting help. + */ +static void guac_dbshell_print_help(guac_dbshell_session* session) { + + guac_dbshell_println(session, "Shell commands:"); + guac_dbshell_println(session, " \\h Display this help text."); + guac_dbshell_println(session, " \\q Disconnect and end the " + "session (also: quit, exit, Ctrl+D)."); + guac_dbshell_println(session, " Ctrl+C Cancel the current input " + "line or running statement."); + + if (session->driver->help_handler != NULL) + session->driver->help_handler(session); + +} + +/** + * Handles the given complete input line as a meta-command if it is one, + * dispatching driver-specific commands to the driver's meta handler. + * + * @param session + * The session which received the line. + * + * @param line + * The null-terminated input line. + * + * @param quit + * Set to true if the meta-command requests that the session end, and + * left unmodified otherwise. + * + * @return + * Non-zero if the line was handled as a meta-command and must not be + * forwarded to the statement splitter, zero otherwise. + */ +static int guac_dbshell_handle_meta(guac_dbshell_session* session, + const char* line, bool* quit) { + + /* Bare "quit" / "exit" end the session */ + if (guac_dbshell_line_is_command(line, "quit") + || guac_dbshell_line_is_command(line, "exit")) { + *quit = true; + return 1; + } + + /* All other meta-commands begin with a backslash */ + const char* command = line; + while (isspace((unsigned char) *command)) + command++; + + if (*command != '\\') + return 0; + + command++; + + if (guac_dbshell_line_is_command(command, "q")) { + *quit = true; + return 1; + } + + if (guac_dbshell_line_is_command(command, "h") + || guac_dbshell_line_is_command(command, "help")) { + guac_dbshell_print_help(session); + return 1; + } + + /* Offer the command to the driver */ + if (session->driver->meta_handler != NULL + && session->driver->meta_handler(session, command)) + return 1; + + guac_dbshell_println(session, "Unknown command \"\\%s\". Type \\h for " + "help.", command); + return 1; + +} + +int guac_dbshell_repl_run(guac_dbshell_session* session) { + + const guac_dbshell_driver* driver = session->driver; + + /* Primary prompt: "name> "; continuation prompt of equal width + * ending in "-> " */ + char prompt[GUAC_DBSHELL_MAX_PROMPT_LENGTH]; + char continuation[GUAC_DBSHELL_MAX_PROMPT_LENGTH]; + + int prompt_length = snprintf(prompt, sizeof(prompt), "%s> ", + driver->name); + if (prompt_length > (int) sizeof(prompt) - 1) + prompt_length = sizeof(prompt) - 1; + if (prompt_length < 3) + prompt_length = 3; + + memset(continuation, ' ', prompt_length); + memcpy(continuation + prompt_length - 3, "-> ", 4); + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(driver->dialect); + + /* The input parser persists across reads so that multi-byte sequences + * spanning reads are handled correctly */ + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + int lost = 0; + bool quit = false; + + while (!quit && session->client->state == GUAC_CLIENT_RUNNING) { + + /* Read the next input line */ + guac_dbshell_read_status status; + char* line = guac_dbshell_line_editor_read(session->term, &parser, + session->history, + guac_dbshell_splitter_pending(splitter) + ? continuation : prompt, + &status); + + /* End of input */ + if (status == GUAC_DBSHELL_READ_CLOSED) + break; + + /* Ctrl+C discards the statement being accumulated */ + if (status == GUAC_DBSHELL_READ_CANCELLED) { + guac_dbshell_splitter_reset(splitter); + continue; + } + + guac_dbshell_history_add(session->history, line); + + /* Handle meta-commands only outside multi-line statements */ + if (!guac_dbshell_splitter_pending(splitter) + && guac_dbshell_handle_meta(session, line, &quit)) { + guac_mem_free(line); + continue; + } + + guac_dbshell_splitter_feed(splitter, line); + guac_mem_free(line); + + if (guac_dbshell_splitter_overflowed(splitter)) { + guac_dbshell_println(session, "ERROR: Statement too long; " + "input discarded."); + continue; + } + + /* Execute each completed statement */ + char* statement; + while (!lost && (statement = + guac_dbshell_splitter_next_statement(splitter)) + != NULL) { + + pthread_mutex_lock(&session->execute_lock); + session->executing = true; + pthread_mutex_unlock(&session->execute_lock); + + int result = driver->execute_handler(session, statement); + + pthread_mutex_lock(&session->execute_lock); + session->executing = false; + pthread_mutex_unlock(&session->execute_lock); + + guac_mem_free(statement); + + if (result == GUAC_DBSHELL_EXECUTE_LOST) { + guac_dbshell_println(session, "The connection to the " + "database server has been lost."); + lost = 1; + } + + } + + if (lost) + break; + + } + + guac_dbshell_splitter_free(splitter); + return lost; + +} diff --git a/src/dbshell/settings.c b/src/dbshell/settings.c new file mode 100644 index 000000000..c964638a3 --- /dev/null +++ b/src/dbshell/settings.c @@ -0,0 +1,459 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "common/defaults.h" +#include "dbshell/settings.h" +#include "terminal/terminal.h" + +#include +#include +#include +#include + +#include +#include + +/** + * The indices of the common arguments within the argument list of any + * database protocol plugin. These indices correspond exactly, in order, to + * the arguments declared by GUAC_DBSHELL_COMMON_ARGS. + */ +enum GUAC_DBSHELL_ARGS_IDX { + + /** + * The hostname or IP address of the database server to connect to. + */ + IDX_HOSTNAME, + + /** + * The TCP port of the database server to connect to. + */ + IDX_PORT, + + /** + * The username to authenticate as. + */ + IDX_USERNAME, + + /** + * The password to authenticate with. + */ + IDX_PASSWORD, + + /** + * The name of the database to use initially. + */ + IDX_DATABASE, + + /** + * The maximum number of seconds to wait for the connection to the + * database server to be established. + */ + IDX_TIMEOUT, + + /** + * The name of the font to use within the terminal. + */ + IDX_FONT_NAME, + + /** + * The size of the font to use within the terminal, in points. + */ + IDX_FONT_SIZE, + + /** + * The color scheme to use, as a series of semicolon-separated + * color-value pairs, or one of the special values "black-white", + * "white-black", "gray-black", or "green-black". + */ + IDX_COLOR_SCHEME, + + /** + * The full absolute path to the directory in which typescripts should + * be written. + */ + IDX_TYPESCRIPT_PATH, + + /** + * The name that should be given to typescripts which are written in + * the given path. + */ + IDX_TYPESCRIPT_NAME, + + /** + * Whether the specified typescript path should automatically be + * created if it does not yet exist. + */ + IDX_CREATE_TYPESCRIPT_PATH, + + /** + * Whether existing files should be appended to when creating a new + * typescript. Disabled by default. + */ + IDX_TYPESCRIPT_WRITE_EXISTING, + + /** + * The full absolute path to the directory in which screen recordings + * should be written. + */ + IDX_RECORDING_PATH, + + /** + * The name that should be given to screen recordings which are written + * in the given path. + */ + IDX_RECORDING_NAME, + + /** + * Whether output which is broadcast to each connected client should + * NOT be included in the session recording. + */ + IDX_RECORDING_EXCLUDE_OUTPUT, + + /** + * Whether changes to mouse state should NOT be included in the session + * recording. + */ + IDX_RECORDING_EXCLUDE_MOUSE, + + /** + * Whether keys pressed and released should be included in the session + * recording. + */ + IDX_RECORDING_INCLUDE_KEYS, + + /** + * Whether clipboard data should be included in the session recording. + */ + IDX_RECORDING_INCLUDE_CLIPBOARD, + + /** + * Whether the specified screen recording path should automatically be + * created if it does not yet exist. + */ + IDX_CREATE_RECORDING_PATH, + + /** + * Whether existing files should be appended to when creating a new + * recording. Disabled by default. + */ + IDX_RECORDING_WRITE_EXISTING, + + /** + * "true" if this connection should be read-only (user input should be + * dropped), "false" or blank otherwise. + */ + IDX_READ_ONLY, + + /** + * ASCII code, as an integer, to use for the backspace key, or + * GUAC_TERMINAL_DEFAULT_BACKSPACE if not specified. + */ + IDX_BACKSPACE, + + /** + * The maximum size of the scrollback buffer in rows. + */ + IDX_SCROLLBACK, + + /** + * The family of codes (e.g. vt100) which will be used when the + * function and keypad keys are pressed. + */ + IDX_FUNC_KEYS_AND_KEYPAD, + + /** + * The maximum number of bytes to allow within the clipboard. + */ + IDX_CLIPBOARD_BUFFER_SIZE, + + /** + * Whether outbound clipboard access should be blocked. If set to + * "true", it will not be possible to copy data from the terminal to + * the client using the clipboard. + */ + IDX_DISABLE_COPY, + + /** + * Whether inbound clipboard access should be blocked. If set to + * "true", it will not be possible to paste data from the client to the + * terminal using the clipboard. + */ + IDX_DISABLE_PASTE, + + /** + * The terminal emulator type presented to the user (e.g. "xterm"). + * "linux" is used if unspecified. + */ + IDX_TERMINAL_TYPE, + + /** + * Whether a Wake-on-LAN packet should be sent to the database server + * prior to connecting. + */ + IDX_WOL_SEND_PACKET, + + /** + * The MAC address to place within the Wake-on-LAN packet. + */ + IDX_WOL_MAC_ADDR, + + /** + * The broadcast address to which the Wake-on-LAN packet should be + * sent. + */ + IDX_WOL_BROADCAST_ADDR, + + /** + * The UDP port to use when sending the Wake-on-LAN packet. + */ + IDX_WOL_UDP_PORT, + + /** + * The number of seconds to wait after sending the Wake-on-LAN packet + * before attempting to connect to the database server. + */ + IDX_WOL_WAIT_TIME, + + GUAC_DBSHELL_ARGS_IDX_COUNT + +} ; + +/* The argument indices must match the argument list */ +_Static_assert(GUAC_DBSHELL_ARGS_IDX_COUNT == GUAC_DBSHELL_COMMON_ARG_COUNT, + "GUAC_DBSHELL_COMMON_ARGS and GUAC_DBSHELL_ARGS_IDX must declare " + "the same number of arguments"); + +guac_dbshell_settings* guac_dbshell_settings_parse(guac_user* user, + const char** protocol_args, int argc, const char** argv, + int expected_argc, int default_port) { + + /* Validate arg count */ + if (argc != expected_argc) { + guac_user_log(user, GUAC_LOG_WARNING, "Incorrect number of " + "connection parameters provided: expected %i, got %i.", + expected_argc, argc); + return NULL; + } + + guac_dbshell_settings* settings = + guac_mem_zalloc(sizeof(guac_dbshell_settings)); + + /* Read hostname */ + settings->hostname = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_HOSTNAME, ""); + + /* Read port */ + settings->port = + guac_user_parse_args_int_bounded(user, protocol_args, argv, + IDX_PORT, default_port, GUAC_ITOA_USHORT_MIN + 1, + GUAC_ITOA_USHORT_MAX); + + /* Read credentials and database (NULL if not provided) */ + settings->username = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_USERNAME, NULL); + + settings->password = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_PASSWORD, NULL); + + settings->database = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_DATABASE, NULL); + + /* Read connect timeout */ + settings->timeout = + guac_user_parse_args_int(user, protocol_args, argv, + IDX_TIMEOUT, GUAC_DBSHELL_DEFAULT_TIMEOUT); + + /* Read display preferences */ + settings->font_name = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_FONT_NAME, GUAC_TERMINAL_DEFAULT_FONT_NAME); + + settings->font_size = + guac_user_parse_args_int(user, protocol_args, argv, + IDX_FONT_SIZE, GUAC_TERMINAL_DEFAULT_FONT_SIZE); + + settings->color_scheme = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_COLOR_SCHEME, GUAC_TERMINAL_DEFAULT_COLOR_SCHEME); + + /* Read typescript preferences */ + settings->typescript_path = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_TYPESCRIPT_PATH, NULL); + + settings->typescript_name = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_TYPESCRIPT_NAME, GUAC_DBSHELL_DEFAULT_TYPESCRIPT_NAME); + + settings->create_typescript_path = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_CREATE_TYPESCRIPT_PATH, false); + + settings->typescript_write_existing = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_TYPESCRIPT_WRITE_EXISTING, false); + + /* Read screen recording preferences */ + settings->recording_path = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_RECORDING_PATH, NULL); + + settings->recording_name = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_RECORDING_NAME, GUAC_DBSHELL_DEFAULT_RECORDING_NAME); + + settings->recording_exclude_output = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_RECORDING_EXCLUDE_OUTPUT, false); + + settings->recording_exclude_mouse = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_RECORDING_EXCLUDE_MOUSE, false); + + settings->recording_include_keys = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_RECORDING_INCLUDE_KEYS, false); + + settings->recording_include_clipboard = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_RECORDING_INCLUDE_CLIPBOARD, false); + + settings->create_recording_path = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_CREATE_RECORDING_PATH, false); + + settings->recording_write_existing = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_RECORDING_WRITE_EXISTING, false); + + /* Read terminal behavior preferences */ + settings->read_only = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_READ_ONLY, false); + + settings->backspace = + guac_user_parse_args_int(user, protocol_args, argv, + IDX_BACKSPACE, GUAC_TERMINAL_DEFAULT_BACKSPACE); + + settings->max_scrollback = + guac_user_parse_args_int(user, protocol_args, argv, + IDX_SCROLLBACK, GUAC_TERMINAL_DEFAULT_MAX_SCROLLBACK); + + settings->func_keys_and_keypad = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_FUNC_KEYS_AND_KEYPAD, ""); + + settings->clipboard_buffer_size = + guac_user_parse_args_int(user, protocol_args, argv, + IDX_CLIPBOARD_BUFFER_SIZE, 0); + + settings->disable_copy = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_DISABLE_COPY, false); + + settings->disable_paste = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_DISABLE_PASTE, false); + + settings->terminal_type = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_TERMINAL_TYPE, "linux"); + + /* Read Wake-on-LAN preferences */ + settings->wol_send_packet = + guac_user_parse_args_boolean(user, protocol_args, argv, + IDX_WOL_SEND_PACKET, false); + + if (settings->wol_send_packet) { + + /* Warn and disable if WoL was requested without a MAC address */ + if (strcmp(argv[IDX_WOL_MAC_ADDR], "") == 0) { + guac_user_log(user, GUAC_LOG_WARNING, "WoL was enabled, but no " + "MAC address was provided. WoL will not be sent."); + settings->wol_send_packet = false; + } + + settings->wol_mac_addr = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_WOL_MAC_ADDR, NULL); + + settings->wol_broadcast_addr = + guac_user_parse_args_string(user, protocol_args, argv, + IDX_WOL_BROADCAST_ADDR, GUAC_WOL_LOCAL_IPV4_BROADCAST); + + settings->wol_udp_port = (unsigned short) + guac_user_parse_args_int_bounded(user, protocol_args, argv, + IDX_WOL_UDP_PORT, GUAC_WOL_PORT, GUAC_ITOA_USHORT_MIN, + GUAC_ITOA_USHORT_MAX); + + settings->wol_wait_time = + guac_user_parse_args_int(user, protocol_args, argv, + IDX_WOL_WAIT_TIME, GUAC_WOL_DEFAULT_BOOT_WAIT_TIME); + + } + + /* Use the dimensions and resolution of the connecting user's optimal + * display */ + settings->width = user->info.optimal_width; + settings->height = user->info.optimal_height; + settings->resolution = user->info.optimal_resolution; + + return settings; + +} + +void guac_dbshell_settings_free(guac_dbshell_settings* settings) { + + if (settings == NULL) + return; + + /* Free network and authentication details */ + guac_mem_free(settings->hostname); + guac_mem_free(settings->username); + guac_mem_free(settings->password); + guac_mem_free(settings->database); + + /* Free display preferences */ + guac_mem_free(settings->font_name); + guac_mem_free(settings->color_scheme); + + /* Free typescript and recording settings */ + guac_mem_free(settings->typescript_path); + guac_mem_free(settings->typescript_name); + guac_mem_free(settings->recording_path); + guac_mem_free(settings->recording_name); + + /* Free terminal behavior settings */ + guac_mem_free(settings->func_keys_and_keypad); + guac_mem_free(settings->terminal_type); + + /* Free Wake-on-LAN settings */ + guac_mem_free(settings->wol_mac_addr); + guac_mem_free(settings->wol_broadcast_addr); + + guac_mem_free(settings); + +} diff --git a/src/dbshell/splitter.c b/src/dbshell/splitter.c new file mode 100644 index 000000000..ead024e9c --- /dev/null +++ b/src/dbshell/splitter.c @@ -0,0 +1,735 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/splitter.h" + +#include + +#include +#include +#include + +/** + * The maximum number of bytes of input which may accumulate within a + * splitter without forming a complete statement. Input beyond this limit is + * discarded, protecting against unbounded memory growth from unterminated + * string literals in large pasted input. + */ +#define GUAC_DBSHELL_MAX_STATEMENT_LENGTH 1048576 + +/** + * The lexical states of the statement scanner. + */ +typedef enum guac_dbshell_lex_state { + + /** + * Outside any string literal or comment. + */ + GUAC_DBSHELL_LEX_NORMAL, + + /** + * Within a single-quoted string literal. + */ + GUAC_DBSHELL_LEX_SINGLE, + + /** + * Within a double-quoted string literal or identifier. + */ + GUAC_DBSHELL_LEX_DOUBLE, + + /** + * Within a backtick-quoted identifier (MySQL). + */ + GUAC_DBSHELL_LEX_BACKTICK, + + /** + * Within a bracket-quoted identifier (Transact-SQL). + */ + GUAC_DBSHELL_LEX_BRACKET, + + /** + * Within a dollar-quoted string literal (PostgreSQL). + */ + GUAC_DBSHELL_LEX_DOLLAR, + + /** + * Within a comment which extends to the end of the line. + */ + GUAC_DBSHELL_LEX_LINE_COMMENT, + + /** + * Within a block comment. + */ + GUAC_DBSHELL_LEX_BLOCK_COMMENT + +} guac_dbshell_lex_state; + +/** + * The maximum length of the tag of a PostgreSQL dollar-quoted string + * literal which the scanner will recognize, in bytes, excluding the + * enclosing dollar signs. + */ +#define GUAC_DBSHELL_MAX_DOLLAR_TAG_LENGTH 64 + +struct guac_dbshell_splitter { + + /** + * The dialect determining the lexical rules applied by the splitter. + */ + guac_dbshell_dialect dialect; + + /** + * The accumulated input which has not yet been emitted as complete + * statements. + */ + char* buffer; + + /** + * The number of bytes of accumulated input. + */ + int length; + + /** + * The number of bytes currently allocated for the input buffer. + */ + int allocated; + + /** + * Whether accumulated input has been discarded because it exceeded + * GUAC_DBSHELL_MAX_STATEMENT_LENGTH. + */ + bool overflowed; + +}; + +/** + * The result of scanning accumulated input for the end of the first + * complete statement. + */ +typedef struct guac_dbshell_scan_result { + + /** + * The index one past the last byte consumed by the statement, including + * its terminator, or -1 if no complete statement was found. + */ + int end; + + /** + * The number of trailing bytes within the consumed region which belong + * to the terminator and must not be included in the statement text. + */ + int terminator_length; + +} guac_dbshell_scan_result; + +/** + * Returns whether the given byte may appear within the tag of a PostgreSQL + * dollar-quoted string literal. + * + * @param c + * The byte to test. + * + * @return + * Non-zero if the byte may appear within a dollar-quote tag, zero + * otherwise. + */ +static int guac_dbshell_is_dollar_tag_byte(char c) { + return isalnum((unsigned char) c) || c == '_'; +} + +/** + * Returns whether the line within the given buffer, spanning the given + * range, consists solely of the given word after leading and trailing + * whitespace is ignored, compared case-insensitively. + * + * @param buffer + * The buffer containing the line. + * + * @param start + * The index of the first byte of the line. + * + * @param end + * The index one past the last byte of the line, not including any + * newline character. + * + * @param word + * The null-terminated word to compare the line against. + * + * @return + * Non-zero if the line consists solely of the given word, zero + * otherwise. + */ +static int guac_dbshell_line_is_word(const char* buffer, int start, int end, + const char* word) { + + /* Ignore leading whitespace */ + while (start < end && isspace((unsigned char) buffer[start])) + start++; + + /* Ignore trailing whitespace */ + while (end > start && isspace((unsigned char) buffer[end - 1])) + end--; + + /* Compare remaining text against word, case-insensitively */ + int word_length = strlen(word); + if (end - start != word_length) + return 0; + + for (int i = 0; i < word_length; i++) { + if (tolower((unsigned char) buffer[start + i]) + != tolower((unsigned char) word[i])) + return 0; + } + + return 1; + +} + +/** + * Reads the next SQL word (a run of alphabetic characters) from the given + * buffer, skipping any preceding whitespace, storing a lowercased copy + * within the given word buffer. + * + * @param buffer + * The buffer to read from. + * + * @param length + * The number of bytes within the buffer. + * + * @param pos + * The index to begin reading at. On return, this will have advanced + * past the word read. + * + * @param word + * The buffer to store the lowercased word within. + * + * @param word_size + * The size of the word buffer, in bytes. Words too long to fit are + * truncated. + */ +static void guac_dbshell_read_word(const char* buffer, int length, int* pos, + char* word, int word_size) { + + int i = *pos; + int written = 0; + + /* Skip whitespace preceding the word */ + while (i < length && isspace((unsigned char) buffer[i])) + i++; + + /* Copy and lowercase the word itself */ + while (i < length && isalpha((unsigned char) buffer[i])) { + if (written < word_size - 1) + word[written++] = tolower((unsigned char) buffer[i]); + i++; + } + + word[written] = '\0'; + *pos = i; + +} + +/** + * Returns whether the statement beginning at the start of the given buffer + * is an Oracle PL/SQL block (or the creation of a procedural object), in + * which case semicolons do not terminate the statement and only a line + * consisting solely of a slash does. + * + * @param buffer + * The buffer containing the accumulated statement text. + * + * @param length + * The number of bytes within the buffer. + * + * @return + * Non-zero if the statement is a PL/SQL block, zero otherwise. + */ +static int guac_dbshell_is_plsql_block(const char* buffer, int length) { + + char word[32]; + int pos = 0; + + /* Skip any comments preceding the statement, so that a leading comment + * does not hide the first keyword */ + for (;;) { + + while (pos < length && isspace((unsigned char) buffer[pos])) + pos++; + + if (pos + 1 < length && buffer[pos] == '-' + && buffer[pos + 1] == '-') { + while (pos < length && buffer[pos] != '\n') + pos++; + } + + else if (pos + 1 < length && buffer[pos] == '/' + && buffer[pos + 1] == '*') { + pos += 2; + while (pos + 1 < length + && !(buffer[pos] == '*' && buffer[pos + 1] == '/')) + pos++; + pos += 2; + } + + else + break; + + } + + /* Examine the first word of the statement */ + guac_dbshell_read_word(buffer, length, &pos, word, sizeof(word)); + + /* Anonymous blocks begin with DECLARE or BEGIN */ + if (strcmp(word, "declare") == 0 || strcmp(word, "begin") == 0) + return 1; + + /* All other blocks begin with CREATE [OR REPLACE] */ + if (strcmp(word, "create") != 0) + return 0; + + guac_dbshell_read_word(buffer, length, &pos, word, sizeof(word)); + if (strcmp(word, "or") == 0) { + + guac_dbshell_read_word(buffer, length, &pos, word, sizeof(word)); + if (strcmp(word, "replace") != 0) + return 0; + + guac_dbshell_read_word(buffer, length, &pos, word, sizeof(word)); + + } + + /* Skip editioning keywords which may precede the object type */ + if (strcmp(word, "editionable") == 0 + || strcmp(word, "noneditionable") == 0) + guac_dbshell_read_word(buffer, length, &pos, word, sizeof(word)); + + /* The statement is procedural if a procedural object type follows */ + return strcmp(word, "function") == 0 + || strcmp(word, "procedure") == 0 + || strcmp(word, "package") == 0 + || strcmp(word, "trigger") == 0 + || strcmp(word, "type") == 0 + || strcmp(word, "library") == 0; + +} + +/** + * Scans the accumulated input of the given splitter for the end of the + * first complete statement, according to the splitter's dialect. + * + * @param splitter + * The splitter whose accumulated input should be scanned. + * + * @param result + * The structure to populate with the scan result. + */ +static void guac_dbshell_scan(guac_dbshell_splitter* splitter, + guac_dbshell_scan_result* result) { + + const char* buffer = splitter->buffer; + int length = splitter->length; + guac_dbshell_dialect dialect = splitter->dialect; + + guac_dbshell_lex_state state = GUAC_DBSHELL_LEX_NORMAL; + + /* The tag of the dollar-quoted literal currently being read */ + char dollar_tag[GUAC_DBSHELL_MAX_DOLLAR_TAG_LENGTH + 1] = { 0 }; + int dollar_tag_length = 0; + + /* Nesting depth of block comments (only PostgreSQL nests) */ + int comment_depth = 0; + + /* Depth of JSON braces/brackets and whether any content has begun */ + int json_depth = 0; + bool json_started = false; + + /* The index of the first byte of the line currently being scanned */ + int line_start = 0; + + /* Whether semicolons terminate the current statement (Oracle PL/SQL + * blocks are terminated only by a slash line) */ + bool semicolon_ends = true; + if (dialect == GUAC_DBSHELL_DIALECT_ORACLE + && guac_dbshell_is_plsql_block(buffer, length)) + semicolon_ends = false; + + result->end = -1; + result->terminator_length = 0; + + for (int i = 0; i < length; i++) { + + char c = buffer[i]; + + switch (state) { + + case GUAC_DBSHELL_LEX_NORMAL: + + /* JSON completion is tested at each end of line */ + if (dialect == GUAC_DBSHELL_DIALECT_JSON) { + + if (c == '\'' || c == '"') { + state = (c == '\'') ? GUAC_DBSHELL_LEX_SINGLE + : GUAC_DBSHELL_LEX_DOUBLE; + json_started = true; + } + else if (c == '{' || c == '[') { + json_depth++; + json_started = true; + } + else if (c == '}' || c == ']') { + if (json_depth > 0) + json_depth--; + } + else if (c == '\n') { + if (json_started && json_depth == 0) { + result->end = i + 1; + result->terminator_length = 1; + return; + } + line_start = i + 1; + } + else if (!isspace((unsigned char) c)) + json_started = true; + + break; + + } + + /* Semicolon ends the statement when permitted */ + if (c == ';' && semicolon_ends) { + result->end = i + 1; + result->terminator_length = 1; + return; + } + + /* Terminator lines (GO, /) are tested at each end of line */ + if (c == '\n') { + + if (dialect == GUAC_DBSHELL_DIALECT_TSQL + && guac_dbshell_line_is_word(buffer, line_start, + i, "go")) { + result->end = i + 1; + result->terminator_length = i + 1 - line_start; + return; + } + + if (dialect == GUAC_DBSHELL_DIALECT_ORACLE + && guac_dbshell_line_is_word(buffer, line_start, + i, "/")) { + result->end = i + 1; + result->terminator_length = i + 1 - line_start; + return; + } + + line_start = i + 1; + break; + + } + + /* String literal and quoted identifier openings */ + if (c == '\'') + state = GUAC_DBSHELL_LEX_SINGLE; + + else if (c == '"') + state = GUAC_DBSHELL_LEX_DOUBLE; + + else if (c == '`' + && dialect == GUAC_DBSHELL_DIALECT_MYSQL) + state = GUAC_DBSHELL_LEX_BACKTICK; + + else if (c == '[' + && dialect == GUAC_DBSHELL_DIALECT_TSQL) + state = GUAC_DBSHELL_LEX_BRACKET; + + /* Dollar-quoted literals (PostgreSQL only) */ + else if (c == '$' + && dialect == GUAC_DBSHELL_DIALECT_PGSQL) { + + /* Find closing dollar sign of the opening tag */ + int tag_end = i + 1; + while (tag_end < length + && guac_dbshell_is_dollar_tag_byte(buffer[tag_end]) + && tag_end - i - 1 < GUAC_DBSHELL_MAX_DOLLAR_TAG_LENGTH) + tag_end++; + + /* A tag beginning with a digit is a positional + * parameter reference ($1), not a dollar quote */ + if (tag_end < length && buffer[tag_end] == '$' + && (tag_end == i + 1 + || !isdigit((unsigned char) buffer[i + 1]))) { + dollar_tag_length = tag_end - i - 1; + memcpy(dollar_tag, buffer + i + 1, dollar_tag_length); + dollar_tag[dollar_tag_length] = '\0'; + state = GUAC_DBSHELL_LEX_DOLLAR; + i = tag_end; + } + + } + + /* MySQL "#" comments */ + else if (c == '#' + && dialect == GUAC_DBSHELL_DIALECT_MYSQL) + state = GUAC_DBSHELL_LEX_LINE_COMMENT; + + /* "--" comments (MySQL requires trailing whitespace) */ + else if (c == '-' && i + 1 < length + && buffer[i + 1] == '-') { + + if (dialect != GUAC_DBSHELL_DIALECT_MYSQL + || i + 2 >= length + || isspace((unsigned char) buffer[i + 2])) { + state = GUAC_DBSHELL_LEX_LINE_COMMENT; + i++; + } + + } + + /* Block comments */ + else if (c == '/' && i + 1 < length + && buffer[i + 1] == '*') { + state = GUAC_DBSHELL_LEX_BLOCK_COMMENT; + comment_depth = 1; + i++; + } + + break; + + case GUAC_DBSHELL_LEX_SINGLE: + case GUAC_DBSHELL_LEX_DOUBLE: + + /* Backslash escapes apply only to MySQL (and JSON strings, + * which use double quotes) */ + if (c == '\\' + && (dialect == GUAC_DBSHELL_DIALECT_MYSQL + || dialect == GUAC_DBSHELL_DIALECT_JSON)) { + if (i + 1 < length) + i++; + break; + } + + /* Close quote, unless doubled */ + if ((state == GUAC_DBSHELL_LEX_SINGLE && c == '\'') + || (state == GUAC_DBSHELL_LEX_DOUBLE && c == '"')) { + + /* Doubled quote characters remain within the literal + * (SQL dialects only) */ + if (dialect != GUAC_DBSHELL_DIALECT_JSON + && i + 1 < length && buffer[i + 1] == c) + i++; + else + state = GUAC_DBSHELL_LEX_NORMAL; + + } + + break; + + case GUAC_DBSHELL_LEX_BACKTICK: + + /* Close backtick, unless doubled */ + if (c == '`') { + if (i + 1 < length && buffer[i + 1] == '`') + i++; + else + state = GUAC_DBSHELL_LEX_NORMAL; + } + + break; + + case GUAC_DBSHELL_LEX_BRACKET: + + /* Close bracket, unless doubled */ + if (c == ']') { + if (i + 1 < length && buffer[i + 1] == ']') + i++; + else + state = GUAC_DBSHELL_LEX_NORMAL; + } + + break; + + case GUAC_DBSHELL_LEX_DOLLAR: + + /* Close only on the exact matching tag */ + if (c == '$' && i + dollar_tag_length + 1 < length + && memcmp(buffer + i + 1, dollar_tag, + dollar_tag_length) == 0 + && buffer[i + dollar_tag_length + 1] == '$') { + i += dollar_tag_length + 1; + state = GUAC_DBSHELL_LEX_NORMAL; + } + + break; + + case GUAC_DBSHELL_LEX_LINE_COMMENT: + + if (c == '\n') { + state = GUAC_DBSHELL_LEX_NORMAL; + line_start = i + 1; + } + + break; + + case GUAC_DBSHELL_LEX_BLOCK_COMMENT: + + /* Only PostgreSQL block comments nest */ + if (c == '/' && i + 1 < length && buffer[i + 1] == '*' + && dialect == GUAC_DBSHELL_DIALECT_PGSQL) { + comment_depth++; + i++; + } + + else if (c == '*' && i + 1 < length + && buffer[i + 1] == '/') { + comment_depth--; + i++; + if (comment_depth == 0) + state = GUAC_DBSHELL_LEX_NORMAL; + } + + break; + + } + + } + +} + +guac_dbshell_splitter* guac_dbshell_splitter_alloc( + guac_dbshell_dialect dialect) { + + guac_dbshell_splitter* splitter = + guac_mem_zalloc(sizeof(guac_dbshell_splitter)); + + splitter->dialect = dialect; + return splitter; + +} + +void guac_dbshell_splitter_free(guac_dbshell_splitter* splitter) { + guac_mem_free(splitter->buffer); + guac_mem_free(splitter); +} + +void guac_dbshell_splitter_feed(guac_dbshell_splitter* splitter, + const char* line) { + + int line_length = strlen(line); + + /* Discard input beyond the statement length limit */ + if (splitter->length + line_length + 1 + > GUAC_DBSHELL_MAX_STATEMENT_LENGTH) { + guac_dbshell_splitter_reset(splitter); + splitter->overflowed = true; + return; + } + + /* Expand buffer to fit the line and its newline */ + int required = splitter->length + line_length + 1; + if (required > splitter->allocated) { + + int allocated = splitter->allocated; + if (allocated == 0) + allocated = 1024; + + while (allocated < required) + allocated *= 2; + + splitter->buffer = guac_mem_realloc(splitter->buffer, allocated); + splitter->allocated = allocated; + + } + + /* Append line and implicit newline */ + memcpy(splitter->buffer + splitter->length, line, line_length); + splitter->length += line_length; + splitter->buffer[splitter->length++] = '\n'; + +} + +char* guac_dbshell_splitter_next_statement( + guac_dbshell_splitter* splitter) { + + for (;;) { + + /* Locate the end of the first complete statement, if any */ + guac_dbshell_scan_result result; + guac_dbshell_scan(splitter, &result); + + if (result.end < 0) + return NULL; + + /* Extract statement text, without its terminator */ + int content_length = result.end - result.terminator_length; + const char* content = splitter->buffer; + + /* Trim leading and trailing whitespace */ + while (content_length > 0 + && isspace((unsigned char) *content)) { + content++; + content_length--; + } + + while (content_length > 0 + && isspace((unsigned char) content[content_length - 1])) + content_length--; + + char* statement = NULL; + if (content_length > 0) { + statement = guac_mem_alloc(content_length + 1); + memcpy(statement, content, content_length); + statement[content_length] = '\0'; + } + + /* Remove the consumed region from the accumulated input */ + splitter->length -= result.end; + memmove(splitter->buffer, splitter->buffer + result.end, + splitter->length); + + /* Skip statements which are empty after trimming */ + if (statement != NULL) + return statement; + + } + +} + +bool guac_dbshell_splitter_pending(guac_dbshell_splitter* splitter) { + + /* Input is pending if any accumulated byte is not whitespace */ + for (int i = 0; i < splitter->length; i++) { + if (!isspace((unsigned char) splitter->buffer[i])) + return true; + } + + return false; + +} + +void guac_dbshell_splitter_reset(guac_dbshell_splitter* splitter) { + splitter->length = 0; + splitter->overflowed = false; +} + +bool guac_dbshell_splitter_overflowed(guac_dbshell_splitter* splitter) { + bool overflowed = splitter->overflowed; + splitter->overflowed = false; + return overflowed; +} diff --git a/src/dbshell/table.c b/src/dbshell/table.c new file mode 100644 index 000000000..e67c82730 --- /dev/null +++ b/src/dbshell/table.c @@ -0,0 +1,434 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "dbshell/buffer.h" +#include "dbshell/line-editor.h" +#include "dbshell/table.h" + +#include +#include +#include + +#include +#include + +/** + * The text rendered in place of database NULL values. + */ +#define GUAC_DBSHELL_TABLE_NULL_TEXT "NULL" + +/** + * The UTF-8 encoding of the marker appended to truncated cell values + * (HORIZONTAL ELLIPSIS). + */ +#define GUAC_DBSHELL_TABLE_ELLIPSIS "\xE2\x80\xA6" + +struct guac_dbshell_table { + + /** + * The terminal the table is rendered to. + */ + guac_terminal* term; + + /** + * The number of columns of the result set. + */ + int num_columns; + + /** + * The sanitized column names. Elements are NULL until assigned. + */ + char** headers; + + /** + * Whether each column holds numeric values and should be + * right-aligned. + */ + bool* numeric; + + /** + * The display width of each column, in terminal columns, excluding + * padding. Widths grow while rows are being buffered and are fixed + * once streaming begins. + */ + int* widths; + + /** + * The buffered leading rows, used to compute column widths before any + * output is produced. Each row is an array of num_columns sanitized + * strings, where NULL denotes the database NULL value. + */ + char*** rows; + + /** + * The number of buffered rows. + */ + int num_buffered; + + /** + * Whether the buffered window has been flushed and subsequent rows are + * being streamed directly to the terminal. + */ + bool streaming; + + /** + * The total number of body rows added to the table. + */ + unsigned long total_rows; + +} ; + +char* guac_dbshell_table_sanitize(const char* value) { + + char* sanitized = guac_strdup(value); + + /* Replace all control characters, preserving multi-byte UTF-8 + * characters (whose bytes all have the high bit set) */ + for (char* c = sanitized; *c != '\0'; c++) { + unsigned char byte = (unsigned char) *c; + if (byte < 0x20 || byte == 0x7F) + *c = ' '; + } + + return sanitized; + +} + +guac_dbshell_table* guac_dbshell_table_begin(guac_terminal* term, + int num_columns) { + + if (num_columns <= 0) + return NULL; + + guac_dbshell_table* table = guac_mem_zalloc(sizeof(guac_dbshell_table)); + + table->term = term; + table->num_columns = num_columns; + + table->headers = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(char*))); + table->numeric = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(bool))); + table->widths = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(int))); + table->rows = guac_mem_zalloc(guac_mem_ckd_mul_or_die( + GUAC_DBSHELL_TABLE_SIZING_ROWS, sizeof(char**))); + + return table; + +} + +/** + * Returns the display width which the given cell value contributes to its + * column, capped at the maximum column width. + * + * @param value + * The sanitized cell value, or NULL for the database NULL value. + * + * @return + * The capped display width of the value. + */ +static int guac_dbshell_table_value_width(const char* value) { + + if (value == NULL) + value = GUAC_DBSHELL_TABLE_NULL_TEXT; + + int width = guac_dbshell_display_width(value, strlen(value)); + if (width > GUAC_DBSHELL_TABLE_MAX_COL_WIDTH) + width = GUAC_DBSHELL_TABLE_MAX_COL_WIDTH; + + return width; + +} + +void guac_dbshell_table_set_header(guac_dbshell_table* table, int column, + const char* name, bool numeric) { + + if (column < 0 || column >= table->num_columns) + return; + + if (name == NULL) + name = ""; + + guac_mem_free(table->headers[column]); + table->headers[column] = guac_dbshell_table_sanitize(name); + table->numeric[column] = numeric; + + /* Columns are always at least as wide as their headers */ + int width = guac_dbshell_table_value_width(table->headers[column]); + if (width > table->widths[column]) + table->widths[column] = width; + +} + +/** + * Appends a horizontal border row ("+-----+-----+") for the given table to + * the given output buffer. + * + * @param table + * The table whose border should be appended. + * + * @param output + * The output buffer to append to. + */ +static void guac_dbshell_table_append_border(guac_dbshell_table* table, + guac_dbshell_buffer* output) { + + for (int i = 0; i < table->num_columns; i++) { + guac_dbshell_buffer_append(output, "+", 1); + guac_dbshell_buffer_append_repeat(output, '-', + table->widths[i] + 2); + } + + guac_dbshell_buffer_append_string(output, "+\r\n"); + +} + +/** + * Appends the given cell value to the given output buffer, padded or + * truncated to exactly the width of the given column and surrounded by + * single spaces. + * + * @param table + * The table containing the cell. + * + * @param output + * The output buffer to append to. + * + * @param column + * The index of the column containing the cell. + * + * @param value + * The sanitized value of the cell, or NULL for the database NULL + * value. + */ +static void guac_dbshell_table_append_cell(guac_dbshell_table* table, + guac_dbshell_buffer* output, int column, const char* value) { + + if (value == NULL) + value = GUAC_DBSHELL_TABLE_NULL_TEXT; + + int col_width = table->widths[column]; + int value_length = strlen(value); + int value_width = guac_dbshell_display_width(value, value_length); + + guac_dbshell_buffer_append(output, " ", 1); + + /* Truncate values wider than the column, reserving one column for the + * truncation marker */ + if (value_width > col_width) { + + int written = 0; + int position = 0; + + while (position < value_length) { + + int next = position; + int width = guac_dbshell_display_next(value, value_length, + &next); + + if (written + width > col_width - 1) + break; + + guac_dbshell_buffer_append(output, value + position, + next - position); + written += width; + position = next; + + } + + guac_dbshell_buffer_append_string(output, + GUAC_DBSHELL_TABLE_ELLIPSIS); + written++; + + /* Pad to the exact column width (a wide character may have stopped + * short of the boundary) */ + guac_dbshell_buffer_append_repeat(output, ' ', + col_width - written); + + } + + /* Pad values narrower than the column, right-aligning numeric + * columns */ + else { + + int padding = col_width - value_width; + + if (table->numeric[column]) { + guac_dbshell_buffer_append_repeat(output, ' ', padding); + guac_dbshell_buffer_append(output, value, value_length); + } + else { + guac_dbshell_buffer_append(output, value, value_length); + guac_dbshell_buffer_append_repeat(output, ' ', padding); + } + + } + + guac_dbshell_buffer_append_string(output, " |"); + +} + +/** + * Appends a complete body or header row to the given output buffer. + * + * @param table + * The table containing the row. + * + * @param output + * The output buffer to append to. + * + * @param values + * An array of exactly num_columns sanitized values, where NULL denotes + * the database NULL value. + */ +static void guac_dbshell_table_append_row(guac_dbshell_table* table, + guac_dbshell_buffer* output, char** values) { + + guac_dbshell_buffer_append(output, "|", 1); + + for (int i = 0; i < table->num_columns; i++) + guac_dbshell_table_append_cell(table, output, i, values[i]); + + guac_dbshell_buffer_append_string(output, "\r\n"); + +} + +/** + * Renders the header and all buffered rows of the given table to its + * terminal, freeing the buffered rows and switching the table to streaming + * output. + * + * @param table + * The table to flush. + */ +static void guac_dbshell_table_flush(guac_dbshell_table* table) { + + guac_dbshell_buffer output; + guac_dbshell_buffer_init(&output); + + /* Render header between two borders */ + guac_dbshell_table_append_border(table, &output); + guac_dbshell_table_append_row(table, &output, table->headers); + guac_dbshell_table_append_border(table, &output); + + /* Render all buffered rows */ + for (int i = 0; i < table->num_buffered; i++) { + + char** row = table->rows[i]; + guac_dbshell_table_append_row(table, &output, row); + + for (int j = 0; j < table->num_columns; j++) + guac_mem_free(row[j]); + guac_mem_free(row); + + } + + guac_terminal_write(table->term, output.data, output.length); + guac_dbshell_buffer_destroy(&output); + + table->num_buffered = 0; + table->streaming = true; + +} + +void guac_dbshell_table_add_row(guac_dbshell_table* table, + const char** values) { + + table->total_rows++; + + /* Once streaming, rows are rendered immediately using fixed widths */ + if (table->streaming) { + + guac_dbshell_buffer output; + guac_dbshell_buffer_init(&output); + + char** sanitized = guac_mem_zalloc(guac_mem_ckd_mul_or_die( + table->num_columns, sizeof(char*))); + + for (int i = 0; i < table->num_columns; i++) { + if (values[i] != NULL) + sanitized[i] = guac_dbshell_table_sanitize(values[i]); + } + + guac_dbshell_table_append_row(table, &output, sanitized); + guac_terminal_write(table->term, output.data, output.length); + guac_dbshell_buffer_destroy(&output); + + for (int i = 0; i < table->num_columns; i++) + guac_mem_free(sanitized[i]); + guac_mem_free(sanitized); + + return; + + } + + /* Otherwise, buffer the row and grow column widths */ + char** row = guac_mem_zalloc(guac_mem_ckd_mul_or_die( + table->num_columns, sizeof(char*))); + + for (int i = 0; i < table->num_columns; i++) { + + if (values[i] != NULL) + row[i] = guac_dbshell_table_sanitize(values[i]); + + int width = guac_dbshell_table_value_width(row[i]); + if (width > table->widths[i]) + table->widths[i] = width; + + } + + table->rows[table->num_buffered++] = row; + + /* Flush and switch to streaming once the sizing window is full */ + if (table->num_buffered >= GUAC_DBSHELL_TABLE_SIZING_ROWS) + guac_dbshell_table_flush(table); + +} + +unsigned long guac_dbshell_table_end(guac_dbshell_table* table) { + + /* Render everything if still buffering */ + if (!table->streaming) + guac_dbshell_table_flush(table); + + /* Closing border */ + guac_dbshell_buffer output; + guac_dbshell_buffer_init(&output); + guac_dbshell_table_append_border(table, &output); + guac_terminal_write(table->term, output.data, output.length); + guac_dbshell_buffer_destroy(&output); + + unsigned long total_rows = table->total_rows; + + /* Free all table resources */ + for (int i = 0; i < table->num_columns; i++) + guac_mem_free(table->headers[i]); + + guac_mem_free(table->headers); + guac_mem_free(table->numeric); + guac_mem_free(table->widths); + guac_mem_free(table->rows); + guac_mem_free(table); + + return total_rows; + +} diff --git a/src/dbshell/tests/Makefile.am b/src/dbshell/tests/Makefile.am new file mode 100644 index 000000000..afd44d791 --- /dev/null +++ b/src/dbshell/tests/Makefile.am @@ -0,0 +1,74 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +# +# Unit tests for the dbshell library +# + +check_PROGRAMS = test_dbshell +TESTS = $(check_PROGRAMS) + +test_dbshell_SOURCES = \ + history/basic.c \ + line-editor/layout.c \ + line-editor/line.c \ + line-editor/parser.c \ + splitter/json.c \ + splitter/sql.c \ + table/sanitize.c + +test_dbshell_CFLAGS = \ + -Werror -Wall -pedantic \ + @DBSHELL_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ + +test_dbshell_LDADD = \ + @CUNIT_LIBS@ \ + @DBSHELL_LTLIB@ \ + @TERMINAL_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + @PTHREAD_LIBS@ + +# +# Autogenerate test runner +# + +GEN_RUNNER = $(top_srcdir)/util/generate-test-runner.pl +CLEANFILES = _generated_runner.c + +_generated_runner.c: $(test_dbshell_SOURCES) + $(AM_V_GEN) $(GEN_RUNNER) $(test_dbshell_SOURCES) > $@ + +nodist_test_dbshell_SOURCES = \ + _generated_runner.c + +# Use automake's TAP test driver for running any tests +LOG_DRIVER = \ + env AM_TAP_AWK='$(AWK)' \ + $(SHELL) $(top_srcdir)/build-aux/tap-driver.sh diff --git a/src/dbshell/tests/history/basic.c b/src/dbshell/tests/history/basic.c new file mode 100644 index 000000000..ad28e2dd6 --- /dev/null +++ b/src/dbshell/tests/history/basic.c @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/history.h" + +#include +#include + +/** + * Verifies that entries added to the history can be retrieved in + * most-recent-first order. + */ +void test_history__add_get(void) { + + guac_dbshell_history* history = guac_dbshell_history_alloc(4); + + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 1)); + + guac_dbshell_history_add(history, "first"); + guac_dbshell_history_add(history, "second"); + guac_dbshell_history_add(history, "third"); + + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 1), "third"); + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 2), "second"); + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 3), "first"); + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 4)); + + guac_dbshell_history_free(history); + +} + +/** + * Verifies that empty lines and consecutive duplicates are not stored. + */ +void test_history__dedup(void) { + + guac_dbshell_history* history = guac_dbshell_history_alloc(4); + + guac_dbshell_history_add(history, ""); + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 1)); + + guac_dbshell_history_add(history, "same"); + guac_dbshell_history_add(history, "same"); + guac_dbshell_history_add(history, "same"); + + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 1), "same"); + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 2)); + + /* Non-consecutive duplicates are stored */ + guac_dbshell_history_add(history, "other"); + guac_dbshell_history_add(history, "same"); + + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 1), "same"); + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 2), "other"); + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 3), "same"); + + guac_dbshell_history_free(history); + +} + +/** + * Verifies that the oldest entries are discarded once the ring is full. + */ +void test_history__wraparound(void) { + + guac_dbshell_history* history = guac_dbshell_history_alloc(3); + + guac_dbshell_history_add(history, "one"); + guac_dbshell_history_add(history, "two"); + guac_dbshell_history_add(history, "three"); + guac_dbshell_history_add(history, "four"); + guac_dbshell_history_add(history, "five"); + + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 1), "five"); + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 2), "four"); + CU_ASSERT_STRING_EQUAL(guac_dbshell_history_get(history, 3), "three"); + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 4)); + + guac_dbshell_history_free(history); + +} + +/** + * Verifies that out-of-range offsets are rejected. + */ +void test_history__bounds(void) { + + guac_dbshell_history* history = guac_dbshell_history_alloc(2); + + guac_dbshell_history_add(history, "entry"); + + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 0)); + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, -1)); + CU_ASSERT_PTR_NULL(guac_dbshell_history_get(history, 2)); + + guac_dbshell_history_free(history); + +} diff --git a/src/dbshell/tests/line-editor/layout.c b/src/dbshell/tests/line-editor/layout.c new file mode 100644 index 000000000..8c3a91c5b --- /dev/null +++ b/src/dbshell/tests/line-editor/layout.c @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/line-editor.h" + +#include +#include +#include + +/** + * Populates the given line with the given text and computes its layout + * with the given prompt and terminal width. + * + * @param prompt + * The prompt string to lay out before the text. + * + * @param text + * The text content of the line. + * + * @param cursor + * The byte position of the cursor within the text. + * + * @param cols + * The terminal width, in columns. + * + * @param layout + * The structure to populate with the computed layout. + */ +static void compute(const char* prompt, const char* text, int cursor, + int cols, guac_dbshell_layout* layout) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + guac_dbshell_line_set(&line, text); + line.cursor = cursor; + + guac_dbshell_line_layout(prompt, &line, cols, layout); + + guac_dbshell_line_destroy(&line); + +} + +/** + * Verifies the layout of input which fits within a single display row. + */ +void test_layout__single_row(void) { + + guac_dbshell_layout layout; + + compute("db> ", "select", 6, 80, &layout); + CU_ASSERT_EQUAL(layout.rows, 1); + CU_ASSERT_EQUAL(layout.cursor_row, 0); + CU_ASSERT_EQUAL(layout.cursor_col, 10); + CU_ASSERT_FALSE(layout.forced_wrap); + + /* Cursor in the middle of the text */ + compute("db> ", "select", 3, 80, &layout); + CU_ASSERT_EQUAL(layout.cursor_col, 7); + + /* Empty line */ + compute("db> ", "", 0, 80, &layout); + CU_ASSERT_EQUAL(layout.rows, 1); + CU_ASSERT_EQUAL(layout.cursor_col, 4); + +} + +/** + * Verifies the layout of input which wraps across display rows. + */ +void test_layout__wrap(void) { + + guac_dbshell_layout layout; + + /* Prompt (4) + text (10) across 10 columns: rows 0-1 */ + compute("db> ", "0123456789", 10, 10, &layout); + CU_ASSERT_EQUAL(layout.rows, 2); + CU_ASSERT_EQUAL(layout.cursor_row, 1); + CU_ASSERT_EQUAL(layout.cursor_col, 4); + CU_ASSERT_FALSE(layout.forced_wrap); + + /* Cursor exactly at a row boundary is displayed at the start of the + * following row */ + compute("db> ", "0123456789", 6, 10, &layout); + CU_ASSERT_EQUAL(layout.cursor_row, 1); + CU_ASSERT_EQUAL(layout.cursor_col, 0); + +} + +/** + * Verifies that text ending exactly at the right margin forces a wrap. + */ +void test_layout__forced_wrap(void) { + + guac_dbshell_layout layout; + + /* Prompt (4) + text (6) = exactly 10 columns */ + compute("db> ", "012345", 6, 10, &layout); + CU_ASSERT_TRUE(layout.forced_wrap); + CU_ASSERT_EQUAL(layout.rows, 2); + CU_ASSERT_EQUAL(layout.cursor_row, 1); + CU_ASSERT_EQUAL(layout.cursor_col, 0); + +} + +/** + * Verifies that wide characters wrap as whole units, leaving a gap at the + * right margin rather than splitting. + */ +void test_layout__wide_chars(void) { + + /* Width data for CJK characters requires a UTF-8 locale; skip this + * test if the build host provides none */ + if (setlocale(LC_CTYPE, "C.UTF-8") == NULL + && setlocale(LC_CTYPE, "en_US.UTF-8") == NULL) + return; + + if (guac_dbshell_display_width("\xE4\xB8\xAD", 3) != 2) + return; + + guac_dbshell_layout layout; + + /* Prompt (3) + "中中中" (6 columns) in 8 columns: the third character + * (columns 7-8 would straddle the margin) wraps whole to row 1 */ + compute(">> ", "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD", 9, 8, &layout); + CU_ASSERT_EQUAL(layout.rows, 2); + CU_ASSERT_EQUAL(layout.cursor_row, 1); + CU_ASSERT_EQUAL(layout.cursor_col, 2); + +} + +/** + * Verifies that degenerate terminal widths are tolerated. + */ +void test_layout__degenerate(void) { + + guac_dbshell_layout layout; + + /* A zero-column terminal is laid out as a single column: five + * characters occupy five rows, with the final character ending at the + * right margin and forcing a wrap onto a sixth row */ + compute("> ", "abc", 3, 0, &layout); + CU_ASSERT_EQUAL(layout.rows, 6); + CU_ASSERT_TRUE(layout.forced_wrap); + + compute("", "", 0, 80, &layout); + CU_ASSERT_EQUAL(layout.rows, 1); + CU_ASSERT_EQUAL(layout.cursor_col, 0); + +} + +/** + * Verifies UTF-8 display width measurement. + */ +void test_layout__display_width(void) { + + CU_ASSERT_EQUAL(guac_dbshell_display_width("abc", 3), 3); + CU_ASSERT_EQUAL(guac_dbshell_display_width("", 0), 0); + + /* "é" is one column in any locale interpretation (either proper width + * or the one-column fallback) */ + CU_ASSERT_EQUAL(guac_dbshell_display_width("\xC3\xA9", 2), 1); + +} diff --git a/src/dbshell/tests/line-editor/line.c b/src/dbshell/tests/line-editor/line.c new file mode 100644 index 000000000..7be2dc7ca --- /dev/null +++ b/src/dbshell/tests/line-editor/line.c @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/line-editor.h" + +#include +#include + +/** + * Verifies insertion of text at the cursor position. + */ +void test_line__insert(void) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + + guac_dbshell_line_insert(&line, "hello", 5); + CU_ASSERT_STRING_EQUAL(line.buffer, "hello"); + CU_ASSERT_EQUAL(line.cursor, 5); + + /* Insert in the middle */ + line.cursor = 4; + guac_dbshell_line_insert(&line, "XY", 2); + CU_ASSERT_STRING_EQUAL(line.buffer, "hellXYo"); + CU_ASSERT_EQUAL(line.cursor, 6); + + guac_dbshell_line_destroy(&line); + +} + +/** + * Verifies that insertion grows the buffer beyond its initial size. + */ +void test_line__insert_growth(void) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + + for (int i = 0; i < 100; i++) + guac_dbshell_line_insert(&line, "0123456789", 10); + + CU_ASSERT_EQUAL(line.length, 1000); + CU_ASSERT_EQUAL(line.cursor, 1000); + CU_ASSERT_EQUAL(strlen(line.buffer), 1000); + + guac_dbshell_line_destroy(&line); + +} + +/** + * Verifies backspace and delete across multi-byte UTF-8 characters. + */ +void test_line__utf8_editing(void) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + + /* "aé中" = 1 + 2 + 3 bytes */ + guac_dbshell_line_insert(&line, "a\xC3\xA9\xE4\xB8\xAD", 6); + CU_ASSERT_EQUAL(line.length, 6); + + /* Backspace removes the entire 3-byte character */ + guac_dbshell_line_backspace(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "a\xC3\xA9"); + CU_ASSERT_EQUAL(line.cursor, 3); + + /* Cursor movement is by character */ + guac_dbshell_line_left(&line); + CU_ASSERT_EQUAL(line.cursor, 1); + guac_dbshell_line_left(&line); + CU_ASSERT_EQUAL(line.cursor, 0); + + /* Delete removes the character at the cursor */ + guac_dbshell_line_delete(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "\xC3\xA9"); + CU_ASSERT_EQUAL(line.cursor, 0); + + guac_dbshell_line_right(&line); + CU_ASSERT_EQUAL(line.cursor, 2); + + guac_dbshell_line_destroy(&line); + +} + +/** + * Verifies that backspace at the beginning and delete at the end of the + * line are no-ops. + */ +void test_line__edit_bounds(void) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + + guac_dbshell_line_backspace(&line); + guac_dbshell_line_delete(&line); + guac_dbshell_line_left(&line); + guac_dbshell_line_right(&line); + + CU_ASSERT_EQUAL(line.length, 0); + CU_ASSERT_EQUAL(line.cursor, 0); + + guac_dbshell_line_insert(&line, "ab", 2); + guac_dbshell_line_delete(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "ab"); + + guac_dbshell_line_destroy(&line); + +} + +/** + * Verifies the kill operations (Ctrl+U, Ctrl+K, Ctrl+W). + */ +void test_line__kill(void) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + + guac_dbshell_line_set(&line, "select * from users"); + + /* Ctrl+W removes the word before the cursor */ + guac_dbshell_line_kill_word(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "select * from "); + + /* Ctrl+W skips whitespace before the word */ + guac_dbshell_line_kill_word(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "select * "); + + /* Ctrl+U removes everything before the cursor */ + line.cursor = 7; + guac_dbshell_line_kill_before(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "* "); + CU_ASSERT_EQUAL(line.cursor, 0); + + /* Ctrl+K removes everything at and after the cursor */ + guac_dbshell_line_set(&line, "abcdef"); + line.cursor = 3; + guac_dbshell_line_kill_after(&line); + CU_ASSERT_STRING_EQUAL(line.buffer, "abc"); + + guac_dbshell_line_destroy(&line); + +} + +/** + * Verifies wholesale replacement of the line content. + */ +void test_line__set(void) { + + guac_dbshell_line line; + guac_dbshell_line_init(&line); + + guac_dbshell_line_insert(&line, "old content", 11); + guac_dbshell_line_set(&line, "new"); + + CU_ASSERT_STRING_EQUAL(line.buffer, "new"); + CU_ASSERT_EQUAL(line.length, 3); + CU_ASSERT_EQUAL(line.cursor, 3); + + guac_dbshell_line_set(&line, ""); + CU_ASSERT_EQUAL(line.length, 0); + CU_ASSERT_EQUAL(line.cursor, 0); + + guac_dbshell_line_destroy(&line); + +} diff --git a/src/dbshell/tests/line-editor/parser.c b/src/dbshell/tests/line-editor/parser.c new file mode 100644 index 000000000..6e1fbb6cc --- /dev/null +++ b/src/dbshell/tests/line-editor/parser.c @@ -0,0 +1,240 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/line-editor.h" + +#include +#include + +/** + * Feeds each byte of the given null-terminated string to the given parser, + * returning the last non-NONE key produced, or GUAC_DBSHELL_KEY_NONE if no + * key was produced. + * + * @param parser + * The parser to feed. + * + * @param bytes + * The null-terminated bytes to feed. + * + * @return + * The last key produced other than GUAC_DBSHELL_KEY_NONE, or + * GUAC_DBSHELL_KEY_NONE if no such key was produced. + */ +static guac_dbshell_key feed_all(guac_dbshell_parser* parser, + const char* bytes) { + + guac_dbshell_key last = GUAC_DBSHELL_KEY_NONE; + + for (const char* c = bytes; *c != '\0'; c++) { + guac_dbshell_key key = guac_dbshell_parser_feed(parser, *c); + if (key != GUAC_DBSHELL_KEY_NONE) + last = key; + } + + return last; + +} + +/** + * Verifies that printable ASCII characters are produced as + * GUAC_DBSHELL_KEY_CHAR with the correct content. + */ +void test_parser__printable(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'a'), + GUAC_DBSHELL_KEY_CHAR); + CU_ASSERT_EQUAL(parser.char_length, 1); + CU_ASSERT_EQUAL(parser.char_buffer[0], 'a'); + +} + +/** + * Verifies that multi-byte UTF-8 characters are accumulated and produced + * as a single GUAC_DBSHELL_KEY_CHAR. + */ +void test_parser__utf8(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + /* U+00E9 (2 bytes) */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\xC3'), + GUAC_DBSHELL_KEY_NONE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\xA9'), + GUAC_DBSHELL_KEY_CHAR); + CU_ASSERT_EQUAL(parser.char_length, 2); + CU_ASSERT_NSTRING_EQUAL(parser.char_buffer, "\xC3\xA9", 2); + + /* U+4E2D (3 bytes) */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\xE4'), + GUAC_DBSHELL_KEY_NONE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\xB8'), + GUAC_DBSHELL_KEY_NONE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\xAD'), + GUAC_DBSHELL_KEY_CHAR); + CU_ASSERT_EQUAL(parser.char_length, 3); + + /* Truncated character followed by a printable character: the invalid + * byte is ignored, the printable character survives */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\xE4'), + GUAC_DBSHELL_KEY_NONE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'x'), + GUAC_DBSHELL_KEY_IGNORED); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'x'), + GUAC_DBSHELL_KEY_CHAR); + +} + +/** + * Verifies that CSI cursor sequences produce the corresponding editing + * keys. + */ +void test_parser__csi(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[A"), GUAC_DBSHELL_KEY_UP); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[B"), GUAC_DBSHELL_KEY_DOWN); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[C"), GUAC_DBSHELL_KEY_RIGHT); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[D"), GUAC_DBSHELL_KEY_LEFT); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[H"), GUAC_DBSHELL_KEY_HOME); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[F"), GUAC_DBSHELL_KEY_END); + + /* Editing keypad variants */ + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[1~"), GUAC_DBSHELL_KEY_HOME); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[4~"), GUAC_DBSHELL_KEY_END); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[7~"), GUAC_DBSHELL_KEY_HOME); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[8~"), GUAC_DBSHELL_KEY_END); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[3~"), GUAC_DBSHELL_KEY_DELETE); + + /* Unknown sequences are consumed and ignored */ + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[5~"), + GUAC_DBSHELL_KEY_IGNORED); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1B[38;5;100m"), + GUAC_DBSHELL_KEY_IGNORED); + + /* Parser returns to ground state afterwards */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'z'), + GUAC_DBSHELL_KEY_CHAR); + +} + +/** + * Verifies that SS3 sequences produce the corresponding editing keys. + */ +void test_parser__ss3(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + CU_ASSERT_EQUAL(feed_all(&parser, "\x1BOA"), GUAC_DBSHELL_KEY_UP); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1BOB"), GUAC_DBSHELL_KEY_DOWN); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1BOH"), GUAC_DBSHELL_KEY_HOME); + CU_ASSERT_EQUAL(feed_all(&parser, "\x1BOF"), GUAC_DBSHELL_KEY_END); + +} + +/** + * Verifies that CR produces ENTER and that the LF of a CRLF pair is + * absorbed, including when the pair spans separate feeds. + */ +void test_parser__crlf(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + /* CR alone is ENTER */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\r'), + GUAC_DBSHELL_KEY_ENTER); + + /* LF following CR is absorbed */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\n'), + GUAC_DBSHELL_KEY_NONE); + + /* LF alone is ENTER */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\n'), + GUAC_DBSHELL_KEY_ENTER); + + /* Non-CR input clears the CR flag */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\r'), + GUAC_DBSHELL_KEY_ENTER); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'a'), + GUAC_DBSHELL_KEY_CHAR); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\n'), + GUAC_DBSHELL_KEY_ENTER); + +} + +/** + * Verifies that control characters map to the expected editing keys. + */ +void test_parser__controls(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x7F'), + GUAC_DBSHELL_KEY_BACKSPACE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x08'), + GUAC_DBSHELL_KEY_BACKSPACE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x01'), + GUAC_DBSHELL_KEY_HOME); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x05'), + GUAC_DBSHELL_KEY_END); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x03'), + GUAC_DBSHELL_KEY_INTERRUPT); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x04'), + GUAC_DBSHELL_KEY_EOF); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x0C'), + GUAC_DBSHELL_KEY_CLEAR); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x15'), + GUAC_DBSHELL_KEY_KILL_LINE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x0B'), + GUAC_DBSHELL_KEY_KILL_TO_END); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x17'), + GUAC_DBSHELL_KEY_KILL_WORD); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x09'), + GUAC_DBSHELL_KEY_TAB); + +} + +/** + * Verifies that Alt+key combinations (ESC followed by a printable + * character) are consumed without inserting the character. + */ +void test_parser__alt_ignored(void) { + + guac_dbshell_parser parser; + guac_dbshell_parser_init(&parser); + + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, '\x1B'), + GUAC_DBSHELL_KEY_NONE); + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'f'), + GUAC_DBSHELL_KEY_IGNORED); + + /* Subsequent input is processed normally */ + CU_ASSERT_EQUAL(guac_dbshell_parser_feed(&parser, 'f'), + GUAC_DBSHELL_KEY_CHAR); + +} diff --git a/src/dbshell/tests/splitter/json.c b/src/dbshell/tests/splitter/json.c new file mode 100644 index 000000000..c811bdde7 --- /dev/null +++ b/src/dbshell/tests/splitter/json.c @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/splitter.h" + +#include +#include +#include +#include + +/** + * Verifies that a balanced JSON document on a single line is produced as + * one statement. + */ +void test_splitter_json__single_line(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_JSON); + + guac_dbshell_splitter_feed(splitter, "{\"ping\": 1}"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + CU_ASSERT_STRING_EQUAL(statement, "{\"ping\": 1}"); + guac_mem_free(statement); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies that a JSON document spanning several lines completes only + * once its braces balance. + */ +void test_splitter_json__multi_line(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_JSON); + + guac_dbshell_splitter_feed(splitter, "{\"find\": \"users\","); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + CU_ASSERT_TRUE(guac_dbshell_splitter_pending(splitter)); + + guac_dbshell_splitter_feed(splitter, " \"filter\": {\"age\": 30},"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + + guac_dbshell_splitter_feed(splitter, " \"batchSize\": [1, 2]}"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + CU_ASSERT_PTR_NOT_NULL(strstr(statement, "\"filter\"")); + guac_mem_free(statement); + + CU_ASSERT_FALSE(guac_dbshell_splitter_pending(splitter)); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies that braces within JSON string literals do not affect + * balancing, including escaped quotes. + */ +void test_splitter_json__strings(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_JSON); + + guac_dbshell_splitter_feed(splitter, + "{\"a\": \"}}{{\", \"b\": \"\\\"}\"}"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + guac_mem_free(statement); + + CU_ASSERT_FALSE(guac_dbshell_splitter_pending(splitter)); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies that non-JSON scalar input completes at the end of its line + * rather than accumulating forever. + */ +void test_splitter_json__scalar(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_JSON); + + guac_dbshell_splitter_feed(splitter, "hello"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + CU_ASSERT_STRING_EQUAL(statement, "hello"); + guac_mem_free(statement); + + guac_dbshell_splitter_free(splitter); + +} diff --git a/src/dbshell/tests/splitter/sql.c b/src/dbshell/tests/splitter/sql.c new file mode 100644 index 000000000..0cf4e715c --- /dev/null +++ b/src/dbshell/tests/splitter/sql.c @@ -0,0 +1,283 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/splitter.h" + +#include +#include +#include +#include + +/** + * Asserts that the next statement produced by the given splitter equals + * the given expected text, freeing the produced statement. + * + * @param splitter + * The splitter to retrieve a statement from. + * + * @param expected + * The expected statement text. + */ +static void assert_statement(guac_dbshell_splitter* splitter, + const char* expected) { + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + CU_ASSERT_STRING_EQUAL(statement, expected); + guac_mem_free(statement); + +} + +/** + * Verifies basic semicolon-terminated splitting, including multiple + * statements within a single line. + */ +void test_splitter_sql__semicolons(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_MYSQL); + + /* Incomplete input produces no statement */ + guac_dbshell_splitter_feed(splitter, "SELECT 1"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + CU_ASSERT_TRUE(guac_dbshell_splitter_pending(splitter)); + + /* Completing the statement produces it, trimmed */ + guac_dbshell_splitter_feed(splitter, " ;"); + assert_statement(splitter, "SELECT 1"); + CU_ASSERT_FALSE(guac_dbshell_splitter_pending(splitter)); + + /* Multiple statements on one line are produced in order */ + guac_dbshell_splitter_feed(splitter, "SELECT 2; SELECT 3;"); + assert_statement(splitter, "SELECT 2"); + assert_statement(splitter, "SELECT 3"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + + /* Empty statements are skipped */ + guac_dbshell_splitter_feed(splitter, ";; ;"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies that semicolons within string literals, quoted identifiers, + * and comments do not terminate statements (MySQL dialect). + */ +void test_splitter_sql__mysql_quoting(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_MYSQL); + + /* Semicolons inside quotes are content */ + guac_dbshell_splitter_feed(splitter, + "SELECT 'a;b', \"c;d\", `e;f`;"); + assert_statement(splitter, "SELECT 'a;b', \"c;d\", `e;f`"); + + /* Backslash-escaped quote does not close the literal */ + guac_dbshell_splitter_feed(splitter, "SELECT 'it\\';s';"); + assert_statement(splitter, "SELECT 'it\\';s'"); + + /* Doubled quote does not close the literal */ + guac_dbshell_splitter_feed(splitter, "SELECT 'it''s; fine';"); + assert_statement(splitter, "SELECT 'it''s; fine'"); + + /* Comments hide semicolons */ + guac_dbshell_splitter_feed(splitter, "SELECT 1 -- comment;"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "# other; comment"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "/* block; comment */;"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + guac_mem_free(statement); + + /* MySQL "--" requires trailing whitespace: "--x" is not a comment */ + guac_dbshell_splitter_reset(splitter); + guac_dbshell_splitter_feed(splitter, "SELECT 1--1;"); + assert_statement(splitter, "SELECT 1--1"); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies PostgreSQL dollar-quoting and nested block comments. + */ +void test_splitter_sql__pgsql(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_PGSQL); + + /* Semicolons within dollar-quoted strings are content */ + guac_dbshell_splitter_feed(splitter, + "CREATE FUNCTION f() RETURNS void AS $$"); + guac_dbshell_splitter_feed(splitter, "BEGIN; SELECT 1; END;"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "$$ LANGUAGE plpgsql;"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + CU_ASSERT_PTR_NOT_NULL(strstr(statement, "BEGIN; SELECT 1; END;")); + guac_mem_free(statement); + + /* Tagged dollar quotes must match exactly */ + guac_dbshell_splitter_feed(splitter, + "SELECT $tag$ ; $notit$ ; $tag$;"); + assert_statement(splitter, "SELECT $tag$ ; $notit$ ; $tag$"); + + /* Positional parameters are not dollar quotes */ + guac_dbshell_splitter_feed(splitter, "SELECT $1;"); + assert_statement(splitter, "SELECT $1"); + + /* Nested block comments */ + guac_dbshell_splitter_feed(splitter, + "SELECT 1 /* outer /* inner; */ still; */;"); + assert_statement(splitter, "SELECT 1 /* outer /* inner; */ still; */"); + + /* Backslash is NOT an escape in PostgreSQL strings */ + guac_dbshell_splitter_feed(splitter, "SELECT 'path\\';"); + assert_statement(splitter, "SELECT 'path\\'"); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies Transact-SQL GO batch separators and bracket quoting. + */ +void test_splitter_sql__tsql(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_TSQL); + + /* Semicolons terminate as usual */ + guac_dbshell_splitter_feed(splitter, "SELECT 1;"); + assert_statement(splitter, "SELECT 1"); + + /* A line consisting solely of GO terminates the batch */ + guac_dbshell_splitter_feed(splitter, "SELECT 2"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "GO"); + assert_statement(splitter, "SELECT 2"); + + /* GO is case-insensitive and tolerates surrounding whitespace */ + guac_dbshell_splitter_feed(splitter, "SELECT 3"); + guac_dbshell_splitter_feed(splitter, " go "); + assert_statement(splitter, "SELECT 3"); + + /* GO within a line is not a separator */ + guac_dbshell_splitter_feed(splitter, "SELECT category GO FROM t;"); + assert_statement(splitter, "SELECT category GO FROM t"); + + /* Semicolons within bracket identifiers are content */ + guac_dbshell_splitter_feed(splitter, "SELECT [a;b] FROM t;"); + assert_statement(splitter, "SELECT [a;b] FROM t"); + + /* Doubled closing bracket remains within the identifier */ + guac_dbshell_splitter_feed(splitter, "SELECT [a]];b] FROM t;"); + assert_statement(splitter, "SELECT [a]];b] FROM t"); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies Oracle PL/SQL block handling: semicolons terminate plain SQL + * but not PL/SQL blocks, which end only with a slash line. + */ +void test_splitter_sql__oracle(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_ORACLE); + + /* Plain SQL is terminated by semicolons */ + guac_dbshell_splitter_feed(splitter, "SELECT 1 FROM dual;"); + assert_statement(splitter, "SELECT 1 FROM dual"); + + /* Anonymous blocks retain internal semicolons until the slash line */ + guac_dbshell_splitter_feed(splitter, "BEGIN"); + guac_dbshell_splitter_feed(splitter, " NULL;"); + guac_dbshell_splitter_feed(splitter, "END;"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "/"); + + char* statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + CU_ASSERT_PTR_NOT_NULL(strstr(statement, "NULL;")); + CU_ASSERT_PTR_NOT_NULL(strstr(statement, "END;")); + guac_mem_free(statement); + + /* CREATE OR REPLACE of procedural objects behaves as a block */ + guac_dbshell_splitter_feed(splitter, + "CREATE OR REPLACE PROCEDURE p AS"); + guac_dbshell_splitter_feed(splitter, "BEGIN NULL; END;"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "/"); + statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + guac_mem_free(statement); + + /* A leading comment does not hide the block keyword */ + guac_dbshell_splitter_feed(splitter, "/* setup */ DECLARE x NUMBER;"); + guac_dbshell_splitter_feed(splitter, "BEGIN x := 1; END;"); + CU_ASSERT_PTR_NULL(guac_dbshell_splitter_next_statement(splitter)); + guac_dbshell_splitter_feed(splitter, "/"); + statement = guac_dbshell_splitter_next_statement(splitter); + CU_ASSERT_PTR_NOT_NULL_FATAL(statement); + guac_mem_free(statement); + + /* CREATE TABLE is not procedural and ends at the semicolon */ + guac_dbshell_splitter_feed(splitter, "CREATE TABLE t (a NUMBER);"); + assert_statement(splitter, "CREATE TABLE t (a NUMBER)"); + + guac_dbshell_splitter_free(splitter); + +} + +/** + * Verifies that oversized accumulated input is discarded and reported. + */ +void test_splitter_sql__overflow(void) { + + guac_dbshell_splitter* splitter = + guac_dbshell_splitter_alloc(GUAC_DBSHELL_DIALECT_MYSQL); + + /* Build a line of 64KB, fed 17 times: exceeds the 1MB limit */ + char* big = guac_mem_alloc(65537); + memset(big, 'x', 65536); + big[0] = '\''; + big[65536] = '\0'; + + CU_ASSERT_FALSE(guac_dbshell_splitter_overflowed(splitter)); + + for (int i = 0; i < 17; i++) + guac_dbshell_splitter_feed(splitter, big); + + CU_ASSERT_TRUE(guac_dbshell_splitter_overflowed(splitter)); + + /* The overflow indication clears once read */ + CU_ASSERT_FALSE(guac_dbshell_splitter_overflowed(splitter)); + + guac_mem_free(big); + guac_dbshell_splitter_free(splitter); + +} diff --git a/src/dbshell/tests/table/sanitize.c b/src/dbshell/tests/table/sanitize.c new file mode 100644 index 000000000..d30eae221 --- /dev/null +++ b/src/dbshell/tests/table/sanitize.c @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "dbshell/table.h" + +#include +#include +#include + +/** + * Verifies that terminal escape sequences within cell data are + * neutralized, preventing injection of terminal control sequences by the + * database server. + */ +void test_sanitize__escape_sequences(void) { + + char* sanitized = guac_dbshell_table_sanitize("a\x1B[31mred\x1B[0m"); + CU_ASSERT_STRING_EQUAL(sanitized, "a [31mred [0m"); + guac_mem_free(sanitized); + +} + +/** + * Verifies that all C0 control characters and DEL are replaced with + * spaces while printable characters survive. + */ +void test_sanitize__control_characters(void) { + + char* sanitized = guac_dbshell_table_sanitize( + "a\tb\nc\rd\x07""e\x7F""f"); + CU_ASSERT_STRING_EQUAL(sanitized, "a b c d e f"); + guac_mem_free(sanitized); + +} + +/** + * Verifies that multi-byte UTF-8 content passes through unmodified. + */ +void test_sanitize__utf8_preserved(void) { + + char* sanitized = guac_dbshell_table_sanitize("caf\xC3\xA9 \xE4\xB8\xAD"); + CU_ASSERT_STRING_EQUAL(sanitized, "caf\xC3\xA9 \xE4\xB8\xAD"); + guac_mem_free(sanitized); + +} + +/** + * Verifies that empty values survive sanitization. + */ +void test_sanitize__empty(void) { + + char* sanitized = guac_dbshell_table_sanitize(""); + CU_ASSERT_STRING_EQUAL(sanitized, ""); + guac_mem_free(sanitized); + +} diff --git a/src/protocols/mongodb/Makefile.am b/src/protocols/mongodb/Makefile.am new file mode 100644 index 000000000..de7f1bf3d --- /dev/null +++ b/src/protocols/mongodb/Makefile.am @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +lib_LTLIBRARIES = libguac-client-mongodb.la + +libguac_client_mongodb_la_SOURCES = \ + client.c \ + mongodb.c + +noinst_HEADERS = \ + mongodb.h + +libguac_client_mongodb_la_CFLAGS = \ + -Werror -Wall -Iinclude \ + @COMMON_INCLUDE@ \ + @DBSHELL_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ \ + @MONGOC_CFLAGS@ + +libguac_client_mongodb_la_LIBADD = \ + @COMMON_LTLIB@ \ + @DBSHELL_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + @TERMINAL_LTLIB@ + +libguac_client_mongodb_la_LDFLAGS = \ + -version-info 0:0:0 \ + @PTHREAD_LIBS@ \ + @MONGOC_LIBS@ diff --git a/src/protocols/mongodb/client.c b/src/protocols/mongodb/client.c new file mode 100644 index 000000000..afe79fbf2 --- /dev/null +++ b/src/protocols/mongodb/client.c @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "mongodb.h" + +#include +#include +#include +#include +#include + +#include + +/** + * The TCP port used by MongoDB servers, if no other port is specified. + */ +#define GUAC_MONGODB_DEFAULT_PORT 27017 + +/** + * All arguments accepted by the MongoDB protocol plugin: the arguments + * common to all database shells, followed by the MongoDB-specific + * arguments. + */ +static const char* GUAC_MONGODB_CLIENT_ARGS[] = { + GUAC_DBSHELL_COMMON_ARGS, + "auth-database", + "use-ssl", + "ssl-ca-file", + NULL +}; + +/** + * The indices of the MongoDB-specific arguments, following the common + * database shell arguments. + */ +enum GUAC_MONGODB_ARGS_IDX { + + /** + * The name of the database to authenticate against (authSource). If + * omitted, authentication is performed against the connected + * database. + */ + IDX_AUTH_DATABASE = GUAC_DBSHELL_COMMON_ARG_COUNT, + + /** + * Whether TLS should be used for the connection. If omitted, TLS is + * not used. + */ + IDX_USE_SSL, + + /** + * The path, on the server hosting guacd, to the certificate authority + * file to verify the database server certificate against. Optional. + */ + IDX_SSL_CA_FILE, + + GUAC_MONGODB_ARGS_COUNT + +} ; + +/** + * Parses the MongoDB-specific arguments into a newly-allocated + * guac_mongodb_extra_settings structure. This implements + * guac_dbshell_plugin.parse_extra_args. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * A newly-allocated guac_mongodb_extra_settings structure. + */ +static void* guac_mongodb_parse_extra_args(guac_user* user, int argc, + const char** argv) { + + guac_mongodb_extra_settings* extra = + guac_mem_zalloc(sizeof(guac_mongodb_extra_settings)); + + extra->auth_database = + guac_user_parse_args_string(user, GUAC_MONGODB_CLIENT_ARGS, argv, + IDX_AUTH_DATABASE, NULL); + + extra->use_ssl = + guac_user_parse_args_boolean(user, GUAC_MONGODB_CLIENT_ARGS, argv, + IDX_USE_SSL, false); + + extra->ssl_ca_file = + guac_user_parse_args_string(user, GUAC_MONGODB_CLIENT_ARGS, argv, + IDX_SSL_CA_FILE, NULL); + + return extra; + +} + +/** + * Frees the given guac_mongodb_extra_settings structure. This implements + * guac_dbshell_plugin.free_extra_args. + * + * @param data + * The structure to free. + */ +static void guac_mongodb_free_extra_args(void* data) { + + guac_mongodb_extra_settings* extra = + (guac_mongodb_extra_settings*) data; + if (extra == NULL) + return; + + guac_mem_free(extra->auth_database); + guac_mem_free(extra->ssl_ca_file); + guac_mem_free(extra); + +} + +/** + * The plugin definition of the MongoDB protocol. + */ +static const guac_dbshell_plugin guac_mongodb_plugin = { + .driver = &guac_mongodb_driver, + .args = GUAC_MONGODB_CLIENT_ARGS, + .argc = GUAC_MONGODB_ARGS_COUNT, + .default_port = GUAC_MONGODB_DEFAULT_PORT, + .parse_extra_args = guac_mongodb_parse_extra_args, + .free_extra_args = guac_mongodb_free_extra_args +}; + +int guac_client_init(guac_client* client) { + return guac_dbshell_client_init(client, &guac_mongodb_plugin); +} diff --git a/src/protocols/mongodb/mongodb.c b/src/protocols/mongodb/mongodb.c new file mode 100644 index 000000000..766403ce3 --- /dev/null +++ b/src/protocols/mongodb/mongodb.c @@ -0,0 +1,610 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "mongodb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +/** + * The name of the database used when neither the connection parameters nor + * the \use meta-command have selected one. + */ +#define GUAC_MONGODB_DEFAULT_DATABASE "test" + +/** + * The number of spaces per indentation level when rendering JSON replies. + */ +#define GUAC_MONGODB_JSON_INDENT 2 + +/** + * Guard ensuring that mongoc_init() is invoked exactly once within the + * guacd process. + */ +static pthread_once_t guac_mongodb_init_once = PTHREAD_ONCE_INIT; + +/** + * Initializes the MongoDB C Driver. Invoked exactly once via + * pthread_once(). + */ +static void guac_mongodb_init(void) { + mongoc_init(); +} + +/** + * Writes the given JSON text to the session's terminal, re-indented for + * readability: newlines and indentation are inserted after structural + * braces, brackets, and commas which lie outside string literals. + * + * @param session + * The session to render to. + * + * @param json + * The null-terminated JSON text to render, as produced by libbson + * (all control characters within string literals are escaped). + */ +static void guac_mongodb_print_json(guac_dbshell_session* session, + const char* json) { + + guac_dbshell_buffer output; + guac_dbshell_buffer_init(&output); + + int depth = 0; + bool in_string = false; + + for (const char* c = json; *c != '\0'; c++) { + + if (in_string) { + + guac_dbshell_buffer_append(&output, c, 1); + + /* Skip escaped characters within strings */ + if (*c == '\\' && *(c + 1) != '\0') { + c++; + guac_dbshell_buffer_append(&output, c, 1); + } + else if (*c == '"') + in_string = false; + + continue; + + } + + switch (*c) { + + case '"': + in_string = true; + guac_dbshell_buffer_append(&output, c, 1); + break; + + /* Open a new indentation level, unless the value is empty */ + case '{': + case '[': + + guac_dbshell_buffer_append(&output, c, 1); + + if (*(c + 1) == '}' || *(c + 1) == ']') { + c++; + guac_dbshell_buffer_append(&output, c, 1); + break; + } + + depth++; + guac_dbshell_buffer_append_string(&output, "\r\n"); + guac_dbshell_buffer_append_repeat(&output, ' ', + depth * GUAC_MONGODB_JSON_INDENT); + break; + + /* Close the current indentation level */ + case '}': + case ']': + + if (depth > 0) + depth--; + + guac_dbshell_buffer_append_string(&output, "\r\n"); + guac_dbshell_buffer_append_repeat(&output, ' ', + depth * GUAC_MONGODB_JSON_INDENT); + guac_dbshell_buffer_append(&output, c, 1); + break; + + /* Break after each member */ + case ',': + + guac_dbshell_buffer_append(&output, c, 1); + guac_dbshell_buffer_append_string(&output, "\r\n"); + guac_dbshell_buffer_append_repeat(&output, ' ', + depth * GUAC_MONGODB_JSON_INDENT); + + /* Swallow the space libbson places after commas */ + if (*(c + 1) == ' ') + c++; + + break; + + default: + guac_dbshell_buffer_append(&output, c, 1); + break; + + } + + } + + guac_dbshell_buffer_append_string(&output, "\r\n"); + guac_terminal_write(session->term, output.data, output.length); + guac_dbshell_buffer_destroy(&output); + +} + +/** + * Renders the given BSON reply document to the session's terminal as + * indented relaxed Extended JSON. + * + * @param session + * The session to render to. + * + * @param reply + * The reply document to render. + */ +static void guac_mongodb_print_reply(guac_dbshell_session* session, + const bson_t* reply) { + + char* json = bson_as_relaxed_extended_json(reply, NULL); + if (json == NULL) + return; + + guac_mongodb_print_json(session, json); + bson_free(json); + +} + +/** + * Returns whether the given libmongoc error denotes loss of connectivity + * with the MongoDB server rather than a failure of the command itself. + * + * @param error + * The error to test. + * + * @return + * Non-zero if the error denotes loss of connectivity, zero otherwise. + */ +static int guac_mongodb_is_connection_error(const bson_error_t* error) { + return error->domain == MONGOC_ERROR_STREAM + || error->domain == MONGOC_ERROR_SERVER_SELECTION; +} + +/** + * Continues and renders the cursor within the given command reply, if any, + * by repeatedly issuing getMore commands until the cursor is exhausted. + * + * @param session + * The session to render to. + * + * @param data + * The MongoDB connection data of the session. + * + * @param reply + * The reply of the originating command, which may contain a cursor + * document. + * + * @return + * Zero on success, non-zero if connectivity with the server was lost + * while iterating the cursor. + */ +static int guac_mongodb_continue_cursor(guac_dbshell_session* session, + guac_mongodb_data* data, const bson_t* reply) { + + bson_iter_t iter; + bson_iter_t cursor; + + /* Nothing to do unless the reply contains a cursor document */ + if (!bson_iter_init_find(&iter, reply, "cursor") + || !BSON_ITER_HOLDS_DOCUMENT(&iter)) + return 0; + + /* Extract the cursor ID */ + int64_t cursor_id = 0; + if (bson_iter_recurse(&iter, &cursor)) + while (bson_iter_next(&cursor)) { + if (strcmp(bson_iter_key(&cursor), "id") == 0 + && BSON_ITER_HOLDS_INT64(&cursor)) + cursor_id = bson_iter_int64(&cursor); + } + + /* Extract the collection name from the cursor namespace ("db.coll") */ + char collection[128] = { 0 }; + + if (bson_iter_init_find(&iter, reply, "cursor") + && bson_iter_recurse(&iter, &cursor) + && bson_iter_find(&cursor, "ns") + && BSON_ITER_HOLDS_UTF8(&cursor)) { + + const char* ns = bson_iter_utf8(&cursor, NULL); + const char* separator = strchr(ns, '.'); + if (separator != NULL) + guac_strlcpy(collection, separator + 1, sizeof(collection)); + + } + + /* Repeatedly fetch further batches until the cursor is exhausted */ + while (cursor_id != 0 && collection[0] != '\0') { + + bson_t get_more; + bson_init(&get_more); + BSON_APPEND_INT64(&get_more, "getMore", cursor_id); + BSON_APPEND_UTF8(&get_more, "collection", collection); + + bson_t batch; + bson_error_t error; + bool success = mongoc_client_command_simple(data->client, + data->database, &get_more, NULL, &batch, &error); + bson_destroy(&get_more); + + if (!success) { + + guac_dbshell_println(session, "ERROR: %s", error.message); + bson_destroy(&batch); + + if (guac_mongodb_is_connection_error(&error)) + return 1; + + return 0; + + } + + guac_mongodb_print_reply(session, &batch); + + /* Read the ID of the continued cursor */ + cursor_id = 0; + if (bson_iter_init_find(&iter, &batch, "cursor") + && bson_iter_recurse(&iter, &cursor) + && bson_iter_find(&cursor, "id") + && BSON_ITER_HOLDS_INT64(&cursor)) + cursor_id = bson_iter_int64(&cursor); + + bson_destroy(&batch); + + } + + return 0; + +} + +/** + * Establishes the connection to the MongoDB server described by the + * settings of the given session. This handler implements + * guac_dbshell_driver.connect_handler. + * + * @param session + * The session on whose behalf the connection is being established. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guac_mongodb_connect(guac_dbshell_session* session) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + + guac_dbshell_client* dbshell_client = + (guac_dbshell_client*) session->client->data; + guac_mongodb_extra_settings* extra = + (guac_mongodb_extra_settings*) dbshell_client->extra_settings; + + pthread_once(&guac_mongodb_init_once, guac_mongodb_init); + + /* Unlike the SQL protocols, a missing username legitimately denotes + * an unauthenticated connection; prompt only for a missing password + * when a username was given */ + if (settings->username != NULL) + guac_dbshell_prompt_credentials(session, false, true); + + mongoc_uri_t* uri = mongoc_uri_new_for_host_port(settings->hostname, + settings->port); + if (uri == NULL) { + guac_dbshell_println(session, "ERROR: Invalid MongoDB server " + "address."); + return 1; + } + + /* Apply credentials, if given */ + if (settings->username != NULL) { + + mongoc_uri_set_username(uri, settings->username); + + if (settings->password != NULL) + mongoc_uri_set_password(uri, settings->password); + + if (extra->auth_database != NULL) + mongoc_uri_set_auth_source(uri, extra->auth_database); + + } + + /* Bound connection establishment and server selection */ + mongoc_uri_set_option_as_int32(uri, MONGOC_URI_CONNECTTIMEOUTMS, + settings->timeout * 1000); + mongoc_uri_set_option_as_int32(uri, MONGOC_URI_SERVERSELECTIONTIMEOUTMS, + settings->timeout * 1000); + + /* Apply TLS settings */ + if (extra->use_ssl) { + + mongoc_uri_set_option_as_bool(uri, MONGOC_URI_TLS, true); + + if (extra->ssl_ca_file != NULL) + mongoc_uri_set_option_as_utf8(uri, MONGOC_URI_TLSCAFILE, + extra->ssl_ca_file); + + } + + mongoc_client_t* client = mongoc_client_new_from_uri(uri); + mongoc_uri_destroy(uri); + + if (client == NULL) { + guac_dbshell_println(session, "ERROR: Unable to create the " + "MongoDB client."); + return 1; + } + + mongoc_client_set_appname(client, "guacd"); + + /* Verify connectivity and authentication with a ping */ + bson_t ping; + bson_init(&ping); + BSON_APPEND_INT32(&ping, "ping", 1); + + bson_error_t error; + bool success = mongoc_client_command_simple(client, "admin", &ping, + NULL, NULL, &error); + bson_destroy(&ping); + + if (!success) { + guac_dbshell_println(session, "ERROR: %s", error.message); + mongoc_client_destroy(client); + return 1; + } + + guac_mongodb_data* data = guac_mem_zalloc(sizeof(guac_mongodb_data)); + data->client = client; + data->database = guac_strdup(settings->database != NULL + ? settings->database : GUAC_MONGODB_DEFAULT_DATABASE); + session->driver_data = data; + + guac_dbshell_println(session, "Connected to %s. Each statement is a " + "MongoDB command document in Extended JSON, for example " + "{\"find\": \"myCollection\"}. Type \\h for help.", + settings->hostname); + guac_dbshell_println(session, ""); + + return 0; + +} + +/** + * Closes the connection to the MongoDB server. This handler implements + * guac_dbshell_driver.disconnect_handler. + * + * @param session + * The session whose connection should be closed. + */ +static void guac_mongodb_disconnect(guac_dbshell_session* session) { + + guac_mongodb_data* data = (guac_mongodb_data*) session->driver_data; + if (data == NULL) + return; + + mongoc_client_destroy(data->client); + guac_mem_free(data->database); + guac_mem_free(data); + session->driver_data = NULL; + +} + +/** + * Executes the given Extended JSON command document against the current + * database, rendering the reply to the session's terminal. This handler + * implements guac_dbshell_driver.execute_handler. + * + * @param session + * The session on whose behalf the command is being executed. + * + * @param statement + * The command document, as Extended JSON. + * + * @return + * GUAC_DBSHELL_EXECUTE_OK if the session may continue, or + * GUAC_DBSHELL_EXECUTE_LOST if the connection has been lost. + */ +static int guac_mongodb_execute(guac_dbshell_session* session, + const char* statement) { + + guac_mongodb_data* data = (guac_mongodb_data*) session->driver_data; + + /* Parse the command document */ + bson_error_t error; + bson_t* command = bson_new_from_json((const uint8_t*) statement, -1, + &error); + + if (command == NULL) { + guac_dbshell_println(session, "ERROR: %s", error.message); + return GUAC_DBSHELL_EXECUTE_OK; + } + + guac_timestamp started = guac_timestamp_current(); + + /* Run the command against the current database */ + bson_t reply; + bool success = mongoc_client_command_simple(data->client, + data->database, command, NULL, &reply, &error); + bson_destroy(command); + + if (!success) { + + guac_dbshell_println(session, "ERROR: %s", error.message); + + /* The reply document may carry further details */ + if (!bson_empty(&reply)) + guac_mongodb_print_reply(session, &reply); + + bson_destroy(&reply); + + if (guac_mongodb_is_connection_error(&error)) + return GUAC_DBSHELL_EXECUTE_LOST; + + return GUAC_DBSHELL_EXECUTE_OK; + + } + + guac_mongodb_print_reply(session, &reply); + + /* Follow any cursor within the reply to completion */ + int lost = guac_mongodb_continue_cursor(session, data, &reply); + bson_destroy(&reply); + + if (lost) + return GUAC_DBSHELL_EXECUTE_LOST; + + guac_dbshell_println(session, "Completed in %li ms.", + (long) (guac_timestamp_current() - started)); + guac_dbshell_println(session, ""); + + return GUAC_DBSHELL_EXECUTE_OK; + +} + +/** + * Handles the MongoDB-specific meta-commands: "\use " switches the + * database commands are directed to, "\show dbs" lists databases, and + * "\show collections" lists the collections of the current database. This + * handler implements guac_dbshell_driver.meta_handler. + * + * @param session + * The session on whose behalf the meta-command is being handled. + * + * @param command + * The meta-command, without its leading backslash. + * + * @return + * Non-zero if the meta-command was recognized and handled, zero + * otherwise. + */ +static int guac_mongodb_meta(guac_dbshell_session* session, + const char* command) { + + guac_mongodb_data* data = (guac_mongodb_data*) session->driver_data; + + /* "\use " switches the current database */ + if (strncmp(command, "use", 3) == 0 + && isspace((unsigned char) command[3])) { + + const char* name = command + 4; + while (isspace((unsigned char) *name)) + name++; + + /* Trim trailing whitespace */ + int length = strlen(name); + while (length > 0 && isspace((unsigned char) name[length - 1])) + length--; + + if (length == 0) { + guac_dbshell_println(session, "Usage: \\use "); + return 1; + } + + guac_mem_free(data->database); + data->database = guac_mem_alloc(length + 1); + memcpy(data->database, name, length); + data->database[length] = '\0'; + + guac_dbshell_println(session, "Switched to database \"%s\".", + data->database); + return 1; + + } + + /* "\show dbs" and "\show collections" */ + if (strcmp(command, "show dbs") == 0 + || strcmp(command, "show databases") == 0) { + guac_mongodb_execute(session, + "{\"listDatabases\": 1, \"nameOnly\": true}"); + return 1; + } + + if (strcmp(command, "show collections") == 0) { + guac_mongodb_execute(session, + "{\"listCollections\": 1, \"nameOnly\": true}"); + return 1; + } + + return 0; + +} + +/** + * Writes the MongoDB-specific help lines. This handler implements + * guac_dbshell_driver.help_handler. + * + * @param session + * The session whose terminal should receive the help text. + */ +static void guac_mongodb_help(guac_dbshell_session* session) { + + guac_dbshell_println(session, " \\use Switch to the " + "given database."); + guac_dbshell_println(session, " \\show dbs List " + "databases."); + guac_dbshell_println(session, " \\show collections List " + "collections of the current database."); + guac_dbshell_println(session, "Statements are MongoDB command " + "documents in Extended JSON, executed against the current " + "database."); + +} + +const guac_dbshell_driver guac_mongodb_driver = { + + .name = "mongodb", + .dialect = GUAC_DBSHELL_DIALECT_JSON, + + .connect_handler = guac_mongodb_connect, + .disconnect_handler = guac_mongodb_disconnect, + .execute_handler = guac_mongodb_execute, + .meta_handler = guac_mongodb_meta, + .help_handler = guac_mongodb_help + +}; diff --git a/src/protocols/mongodb/mongodb.h b/src/protocols/mongodb/mongodb.h new file mode 100644 index 000000000..a75fc6ec4 --- /dev/null +++ b/src/protocols/mongodb/mongodb.h @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_MONGODB_H +#define GUAC_MONGODB_H + +/** + * Declarations for the MongoDB database shell driver, which communicates + * with MongoDB servers via the MongoDB C Driver (libmongoc). The shell + * accepts MongoDB database commands as Extended JSON documents, one + * command per statement. + * + * @file mongodb.h + */ + +#include "config.h" + +#include +#include + +#include + +/** + * The database-specific settings of a MongoDB connection, parsed from the + * arguments following the common database shell arguments. + */ +typedef struct guac_mongodb_extra_settings { + + /** + * The name of the database to authenticate against (authSource), or + * NULL to authenticate against the connected database. + */ + char* auth_database; + + /** + * Whether TLS should be used for the connection. + */ + bool use_ssl; + + /** + * The path, on the server hosting guacd, to the certificate authority + * file to verify the database server certificate against, or NULL if + * not specified. + */ + char* ssl_ca_file; + +} guac_mongodb_extra_settings; + +/** + * The per-connection data of the MongoDB driver. + */ +typedef struct guac_mongodb_data { + + /** + * The MongoDB client handle. + */ + mongoc_client_t* client; + + /** + * The name of the database commands are currently directed to, as + * changed by the \use meta-command. + */ + char* database; + +} guac_mongodb_data; + +/** + * The database shell driver implementing the MongoDB protocol. + */ +extern const guac_dbshell_driver guac_mongodb_driver; + +#endif diff --git a/src/protocols/mssql/Makefile.am b/src/protocols/mssql/Makefile.am new file mode 100644 index 000000000..14e589b0a --- /dev/null +++ b/src/protocols/mssql/Makefile.am @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +lib_LTLIBRARIES = libguac-client-mssql.la + +libguac_client_mssql_la_SOURCES = \ + client.c \ + mssql.c + +noinst_HEADERS = \ + mssql.h + +libguac_client_mssql_la_CFLAGS = \ + -Werror -Wall -Iinclude \ + @COMMON_INCLUDE@ \ + @DBSHELL_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ \ + @FREETDS_CFLAGS@ + +libguac_client_mssql_la_LIBADD = \ + @COMMON_LTLIB@ \ + @DBSHELL_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + @TERMINAL_LTLIB@ + +libguac_client_mssql_la_LDFLAGS = \ + -version-info 0:0:0 \ + @PTHREAD_LIBS@ \ + @FREETDS_LIBS@ diff --git a/src/protocols/mssql/client.c b/src/protocols/mssql/client.c new file mode 100644 index 000000000..8b78f7cf0 --- /dev/null +++ b/src/protocols/mssql/client.c @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "mssql.h" + +#include +#include +#include +#include +#include + +#include + +/** + * The TCP port used by SQL Server, if no other port is specified. + */ +#define GUAC_MSSQL_DEFAULT_PORT 1433 + +/** + * All arguments accepted by the SQL Server protocol plugin: the arguments + * common to all database shells, followed by the SQL Server-specific + * arguments. + */ +static const char* GUAC_MSSQL_CLIENT_ARGS[] = { + GUAC_DBSHELL_COMMON_ARGS, + "tds-version", + NULL +}; + +/** + * The indices of the SQL Server-specific arguments, following the common + * database shell arguments. + */ +enum GUAC_MSSQL_ARGS_IDX { + + /** + * The TDS protocol version to use, one of "7.1", "7.2", "7.3", or + * "7.4". If omitted, the version is negotiated automatically. + */ + IDX_TDS_VERSION = GUAC_DBSHELL_COMMON_ARG_COUNT, + + GUAC_MSSQL_ARGS_COUNT + +} ; + +/** + * Parses the SQL Server-specific arguments into a newly-allocated + * guac_mssql_extra_settings structure. This implements + * guac_dbshell_plugin.parse_extra_args. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * A newly-allocated guac_mssql_extra_settings structure. + */ +static void* guac_mssql_parse_extra_args(guac_user* user, int argc, + const char** argv) { + + guac_mssql_extra_settings* extra = + guac_mem_zalloc(sizeof(guac_mssql_extra_settings)); + + extra->tds_version = + guac_user_parse_args_string(user, GUAC_MSSQL_CLIENT_ARGS, argv, + IDX_TDS_VERSION, NULL); + + return extra; + +} + +/** + * Frees the given guac_mssql_extra_settings structure. This implements + * guac_dbshell_plugin.free_extra_args. + * + * @param data + * The structure to free. + */ +static void guac_mssql_free_extra_args(void* data) { + + guac_mssql_extra_settings* extra = (guac_mssql_extra_settings*) data; + if (extra == NULL) + return; + + guac_mem_free(extra->tds_version); + guac_mem_free(extra); + +} + +/** + * The plugin definition of the SQL Server protocol. + */ +static const guac_dbshell_plugin guac_mssql_plugin = { + .driver = &guac_mssql_driver, + .args = GUAC_MSSQL_CLIENT_ARGS, + .argc = GUAC_MSSQL_ARGS_COUNT, + .default_port = GUAC_MSSQL_DEFAULT_PORT, + .parse_extra_args = guac_mssql_parse_extra_args, + .free_extra_args = guac_mssql_free_extra_args +}; + +int guac_client_init(guac_client* client) { + return guac_dbshell_client_init(client, &guac_mssql_plugin); +} diff --git a/src/protocols/mssql/mssql.c b/src/protocols/mssql/mssql.c new file mode 100644 index 000000000..e053aa406 --- /dev/null +++ b/src/protocols/mssql/mssql.c @@ -0,0 +1,580 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "mssql.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +/** + * The maximum number of bytes of a single column value fetched from the + * server, including the null terminator. Longer values are truncated by + * the client library. + */ +#define GUAC_MSSQL_MAX_VALUE_LENGTH 8192 + +/** + * Returns whether the given CT-Library datatype holds numeric values and + * should be right-aligned when rendered. + * + * @param datatype + * The CS_*_TYPE constant to test. + * + * @return + * Non-zero if the datatype is numeric, zero otherwise. + */ +static int guac_mssql_is_numeric(CS_INT datatype) { + return datatype == CS_TINYINT_TYPE + || datatype == CS_SMALLINT_TYPE + || datatype == CS_INT_TYPE + || datatype == CS_BIGINT_TYPE + || datatype == CS_REAL_TYPE + || datatype == CS_FLOAT_TYPE + || datatype == CS_MONEY_TYPE + || datatype == CS_MONEY4_TYPE + || datatype == CS_NUMERIC_TYPE + || datatype == CS_DECIMAL_TYPE; +} + +/** + * Returns the session associated with the given CT-Library connection, as + * previously stored via the CS_USERDATA property. + * + * @param connection + * The connection whose session should be retrieved. + * + * @return + * The session associated with the connection, or NULL if no session + * has been stored. + */ +static guac_dbshell_session* guac_mssql_get_session( + CS_CONNECTION* connection) { + + guac_dbshell_session* session = NULL; + CS_INT length = 0; + + if (ct_con_props(connection, CS_GET, CS_USERDATA, &session, + sizeof(session), &length) != CS_SUCCEED) + return NULL; + + return session; + +} + +/** + * Client message callback registered with CT-Library, rendering client-side + * errors (connection failures, protocol errors, etc.) to the terminal of + * the session associated with the connection. + * + * @param context + * The CT-Library context. + * + * @param connection + * The connection the message applies to. + * + * @param message + * The client message. + * + * @return + * Always CS_SUCCEED. + */ +static CS_RETCODE guac_mssql_client_message(CS_CONTEXT* context, + CS_CONNECTION* connection, CS_CLIENTMSG* message) { + + guac_dbshell_session* session = guac_mssql_get_session(connection); + if (session != NULL) + guac_dbshell_println(session, "ERROR: %s", message->msgstring); + + return CS_SUCCEED; + +} + +/** + * Server message callback registered with CT-Library, rendering messages + * raised by the SQL Server (errors, PRINT output, informational messages) + * to the terminal of the session associated with the connection. + * + * @param context + * The CT-Library context. + * + * @param connection + * The connection the message applies to. + * + * @param message + * The server message. + * + * @return + * Always CS_SUCCEED. + */ +static CS_RETCODE guac_mssql_server_message(CS_CONTEXT* context, + CS_CONNECTION* connection, CS_SERVERMSG* message) { + + guac_dbshell_session* session = guac_mssql_get_session(connection); + if (session == NULL) + return CS_SUCCEED; + + /* Severity 0 messages are informational output (e.g. PRINT) */ + if (message->severity == 0) + guac_dbshell_println(session, "%s", message->text); + else + guac_dbshell_println(session, "Msg %ld, Level %ld, State %ld: %s", + (long) message->msgnumber, (long) message->severity, + (long) message->state, message->text); + + return CS_SUCCEED; + +} + +/** + * Renders the current row result set of the given command to the session's + * terminal as a table. + * + * @param session + * The session to render to. + * + * @param command + * The command whose row results should be rendered. + * + * @return + * The number of rows rendered, or -1 if results could not be + * processed. + */ +static long guac_mssql_render_rows(guac_dbshell_session* session, + CS_COMMAND* command) { + + CS_INT num_columns = 0; + if (ct_res_info(command, CS_NUMDATA, &num_columns, CS_UNUSED, NULL) + != CS_SUCCEED || num_columns <= 0) + return -1; + + /* Per-column fetch buffers, bound once for all rows */ + char** values = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(char*))); + CS_SMALLINT* indicators = guac_mem_zalloc(guac_mem_ckd_mul_or_die( + num_columns, sizeof(CS_SMALLINT))); + + guac_dbshell_table* table = guac_dbshell_table_begin(session->term, + num_columns); + + /* Describe and bind each column as null-terminated text */ + for (CS_INT i = 0; i < num_columns; i++) { + + CS_DATAFMT column; + memset(&column, 0, sizeof(column)); + ct_describe(command, i + 1, &column); + + guac_dbshell_table_set_header(table, i, column.name, + guac_mssql_is_numeric(column.datatype)); + + values[i] = guac_mem_alloc(GUAC_MSSQL_MAX_VALUE_LENGTH); + + CS_DATAFMT binding; + memset(&binding, 0, sizeof(binding)); + binding.datatype = CS_CHAR_TYPE; + binding.format = CS_FMT_NULLTERM; + binding.maxlength = GUAC_MSSQL_MAX_VALUE_LENGTH; + binding.count = 1; + + ct_bind(command, i + 1, &binding, values[i], NULL, + &indicators[i]); + + } + + /* Fetch and render all rows */ + long rows = 0; + CS_RETCODE result; + CS_INT fetched; + + const char** row = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(char*))); + + while ((result = ct_fetch(command, CS_UNUSED, CS_UNUSED, CS_UNUSED, + &fetched)) == CS_SUCCEED + || result == CS_ROW_FAIL) { + + if (result == CS_ROW_FAIL) + continue; + + for (CS_INT i = 0; i < num_columns; i++) + row[i] = (indicators[i] == -1) ? NULL : values[i]; + + guac_dbshell_table_add_row(table, row); + rows++; + + } + + guac_dbshell_table_end(table); + + for (CS_INT i = 0; i < num_columns; i++) + guac_mem_free(values[i]); + guac_mem_free(values); + guac_mem_free(indicators); + guac_mem_free(row); + + /* A fetch result other than end-of-data denotes failure */ + if (result != CS_END_DATA) + return -1; + + return rows; + +} + +/** + * Establishes the connection to the SQL Server described by the settings + * of the given session. This handler implements + * guac_dbshell_driver.connect_handler. + * + * @param session + * The session on whose behalf the connection is being established. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guac_mssql_connect(guac_dbshell_session* session) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + + guac_dbshell_client* dbshell_client = + (guac_dbshell_client*) session->client->data; + guac_mssql_extra_settings* extra = + (guac_mssql_extra_settings*) dbshell_client->extra_settings; + + /* SQL Server authentication always involves a username and password; + * prompt for whichever is missing */ + guac_dbshell_prompt_credentials(session, true, true); + + /* Allocate and initialize the library context */ + CS_CONTEXT* context = NULL; + if (cs_ctx_alloc(CS_VERSION_100, &context) != CS_SUCCEED + || ct_init(context, CS_VERSION_100) != CS_SUCCEED) { + + guac_dbshell_println(session, "ERROR: Unable to initialize the " + "FreeTDS CT-Library context."); + + if (context != NULL) + cs_ctx_drop(context); + + return 1; + + } + + /* Bound the connection attempt */ + CS_INT timeout = settings->timeout; + ct_config(context, CS_SET, CS_LOGIN_TIMEOUT, &timeout, CS_UNUSED, + NULL); + + /* Route client and server messages to the terminal */ + ct_callback(context, NULL, CS_SET, CS_CLIENTMSG_CB, + (CS_VOID*) guac_mssql_client_message); + ct_callback(context, NULL, CS_SET, CS_SERVERMSG_CB, + (CS_VOID*) guac_mssql_server_message); + + /* Allocate the connection */ + CS_CONNECTION* connection = NULL; + if (ct_con_alloc(context, &connection) != CS_SUCCEED) { + guac_dbshell_println(session, "ERROR: Unable to allocate the " + "database connection."); + ct_exit(context, CS_FORCE_EXIT); + cs_ctx_drop(context); + return 1; + } + + /* Associate the session with the connection for message callbacks */ + guac_dbshell_session* userdata = session; + ct_con_props(connection, CS_SET, CS_USERDATA, &userdata, + sizeof(userdata), NULL); + + /* Apply credentials */ + ct_con_props(connection, CS_SET, CS_USERNAME, + settings->username, CS_NULLTERM, NULL); + ct_con_props(connection, CS_SET, CS_PASSWORD, + settings->password != NULL ? settings->password : "", + CS_NULLTERM, NULL); + ct_con_props(connection, CS_SET, CS_APPNAME, + (CS_VOID*) "guacd", CS_NULLTERM, NULL); + + /* Request a specific TDS protocol version if configured */ + if (extra->tds_version != NULL) { + + CS_INT version = 0; + + if (strcmp(extra->tds_version, "7.1") == 0) + version = CS_TDS_71; + else if (strcmp(extra->tds_version, "7.2") == 0) + version = CS_TDS_72; + else if (strcmp(extra->tds_version, "7.3") == 0) + version = CS_TDS_73; + else if (strcmp(extra->tds_version, "7.4") == 0) + version = CS_TDS_74; + + if (version != 0) + ct_con_props(connection, CS_SET, CS_TDS_VERSION, &version, + CS_UNUSED, NULL); + + } + + /* Use UTF-8 for all character data */ + CS_LOCALE* locale = NULL; + if (cs_loc_alloc(context, &locale) == CS_SUCCEED) { + + if (cs_locale(context, CS_SET, locale, CS_SYB_CHARSET, + (CS_CHAR*) "UTF-8", CS_NULLTERM, NULL) == CS_SUCCEED) + ct_con_props(connection, CS_SET, CS_LOC_PROP, locale, + CS_UNUSED, NULL); + + cs_loc_drop(context, locale); + + } + + /* Connect directly to the given host and port, bypassing any + * freetds.conf server definitions */ + char server_addr[512]; + snprintf(server_addr, sizeof(server_addr), "%s %i", + settings->hostname, settings->port); + ct_con_props(connection, CS_SET, CS_SERVERADDR, server_addr, + CS_NULLTERM, NULL); + + if (ct_connect(connection, NULL, 0) != CS_SUCCEED) { + + /* Details will have been rendered by the message callbacks */ + guac_dbshell_println(session, "ERROR: Unable to connect to the " + "SQL Server database."); + + ct_con_drop(connection); + ct_exit(context, CS_FORCE_EXIT); + cs_ctx_drop(context); + return 1; + + } + + guac_mssql_data* data = guac_mem_zalloc(sizeof(guac_mssql_data)); + data->context = context; + data->connection = connection; + session->driver_data = data; + + guac_dbshell_println(session, "Connected to %s. Statements end with " + "';' or a line containing only \"GO\". Type \\h for help.", + settings->hostname); + guac_dbshell_println(session, ""); + + /* Switch to the requested database, if any */ + if (settings->database != NULL) { + + /* Quote the database name, doubling any closing brackets */ + guac_dbshell_buffer use_statement; + guac_dbshell_buffer_init(&use_statement); + guac_dbshell_buffer_append_string(&use_statement, "USE ["); + + for (char* c = settings->database; *c != '\0'; c++) { + guac_dbshell_buffer_append(&use_statement, c, 1); + if (*c == ']') + guac_dbshell_buffer_append(&use_statement, "]", 1); + } + + guac_dbshell_buffer_append(&use_statement, "]", 1); + guac_dbshell_buffer_append(&use_statement, "", 1); + + session->driver->execute_handler(session, use_statement.data); + guac_dbshell_buffer_destroy(&use_statement); + + } + + return 0; + +} + +/** + * Closes the connection to the SQL Server database. This handler + * implements guac_dbshell_driver.disconnect_handler. + * + * @param session + * The session whose connection should be closed. + */ +static void guac_mssql_disconnect(guac_dbshell_session* session) { + + guac_mssql_data* data = (guac_mssql_data*) session->driver_data; + if (data == NULL) + return; + + ct_close(data->connection, CS_FORCE_CLOSE); + ct_con_drop(data->connection); + ct_exit(data->context, CS_FORCE_EXIT); + cs_ctx_drop(data->context); + + guac_mem_free(data); + session->driver_data = NULL; + +} + +/** + * Executes the given statement against the SQL Server database, rendering + * all result sets and errors to the session's terminal. This handler + * implements guac_dbshell_driver.execute_handler. + * + * @param session + * The session on whose behalf the statement is being executed. + * + * @param statement + * The statement to execute. + * + * @return + * GUAC_DBSHELL_EXECUTE_OK if the session may continue, or + * GUAC_DBSHELL_EXECUTE_LOST if the connection has been lost. + */ +static int guac_mssql_execute(guac_dbshell_session* session, + const char* statement) { + + guac_mssql_data* data = (guac_mssql_data*) session->driver_data; + + guac_timestamp started = guac_timestamp_current(); + + CS_COMMAND* command = NULL; + if (ct_cmd_alloc(data->connection, &command) != CS_SUCCEED) + return GUAC_DBSHELL_EXECUTE_LOST; + + /* Dispatch the statement */ + if (ct_command(command, CS_LANG_CMD, (CS_CHAR*) statement, CS_NULLTERM, + CS_UNUSED) != CS_SUCCEED + || ct_send(command) != CS_SUCCEED) { + ct_cmd_drop(command); + return GUAC_DBSHELL_EXECUTE_LOST; + } + + /* Process every result of the statement */ + CS_RETCODE code; + CS_INT result_type; + bool failed = false; + + while ((code = ct_results(command, &result_type)) == CS_SUCCEED) { + + switch (result_type) { + + /* Render returned rows as a table */ + case CS_ROW_RESULT: { + + long rows = guac_mssql_render_rows(session, command); + if (rows < 0) { + failed = true; + break; + } + + guac_dbshell_print_summary(session, (unsigned long) rows, + 0, guac_timestamp_current() - started); + + break; + } + + /* Report affected rows for statements returning no rows */ + case CS_CMD_SUCCEED: + case CS_CMD_DONE: { + + CS_INT affected = 0; + if (ct_res_info(command, CS_ROW_COUNT, &affected, + CS_UNUSED, NULL) == CS_SUCCEED + && affected >= 0 + && result_type == CS_CMD_DONE) + guac_dbshell_print_summary(session, + (unsigned long) affected, 1, + guac_timestamp_current() - started); + + break; + } + + /* Statement errors will have been rendered by the server + * message callback */ + case CS_CMD_FAIL: + break; + + /* Discard any other result types (computed results, etc.) */ + default: + ct_cancel(NULL, command, CS_CANCEL_CURRENT); + break; + + } + + if (failed) + break; + + } + + ct_cmd_drop(command); + guac_dbshell_println(session, ""); + + /* Results ending for any reason other than completion (or a fetch + * failure) means the connection is no longer usable */ + if (failed || code == CS_FAIL) { + + /* Verify the connection is still alive */ + CS_INT status = 0; + if (ct_con_props(data->connection, CS_GET, CS_CON_STATUS, &status, + CS_UNUSED, NULL) != CS_SUCCEED + || (status & CS_CONSTAT_DEAD)) + return GUAC_DBSHELL_EXECUTE_LOST; + + } + + return GUAC_DBSHELL_EXECUTE_OK; + +} + +/** + * Interrupts the statement currently executing on the given session by + * sending a TDS attention, as sqsh and sqlcmd do on Ctrl+C. This handler + * implements guac_dbshell_driver.cancel_handler. + * + * @param session + * The session whose current statement should be interrupted. + */ +static void guac_mssql_cancel(guac_dbshell_session* session) { + + guac_mssql_data* data = (guac_mssql_data*) session->driver_data; + if (data == NULL) + return; + + ct_cancel(data->connection, NULL, CS_CANCEL_ATTN); + +} + +const guac_dbshell_driver guac_mssql_driver = { + + .name = "mssql", + .dialect = GUAC_DBSHELL_DIALECT_TSQL, + + .connect_handler = guac_mssql_connect, + .disconnect_handler = guac_mssql_disconnect, + .execute_handler = guac_mssql_execute, + .cancel_handler = guac_mssql_cancel + +}; diff --git a/src/protocols/mssql/mssql.h b/src/protocols/mssql/mssql.h new file mode 100644 index 000000000..48f06c229 --- /dev/null +++ b/src/protocols/mssql/mssql.h @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_MSSQL_H +#define GUAC_MSSQL_H + +/** + * Declarations for the SQL Server database shell driver, which + * communicates with Microsoft SQL Server (and Sybase) servers via the + * CT-Library interface of FreeTDS. + * + * @file mssql.h + */ + +#include "config.h" + +#include +#include + +#include + +/** + * The database-specific settings of a SQL Server connection, parsed from + * the arguments following the common database shell arguments. + */ +typedef struct guac_mssql_extra_settings { + + /** + * The TDS protocol version to use, one of "7.1", "7.2", "7.3", or + * "7.4", or NULL if the version should be negotiated automatically. + */ + char* tds_version; + +} guac_mssql_extra_settings; + +/** + * The per-connection data of the SQL Server driver. + */ +typedef struct guac_mssql_data { + + /** + * The CT-Library context. + */ + CS_CONTEXT* context; + + /** + * The connection to the SQL Server database. + */ + CS_CONNECTION* connection; + +} guac_mssql_data; + +/** + * The database shell driver implementing the SQL Server protocol. + */ +extern const guac_dbshell_driver guac_mssql_driver; + +#endif diff --git a/src/protocols/mysql/Makefile.am b/src/protocols/mysql/Makefile.am new file mode 100644 index 000000000..da6d94557 --- /dev/null +++ b/src/protocols/mysql/Makefile.am @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +lib_LTLIBRARIES = libguac-client-mysql.la + +libguac_client_mysql_la_SOURCES = \ + client.c \ + mysql.c + +noinst_HEADERS = \ + mysql-driver.h + +libguac_client_mysql_la_CFLAGS = \ + -Werror -Wall -Iinclude \ + @COMMON_INCLUDE@ \ + @DBSHELL_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ \ + @MARIADB_CFLAGS@ + +libguac_client_mysql_la_LIBADD = \ + @COMMON_LTLIB@ \ + @DBSHELL_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + @TERMINAL_LTLIB@ + +libguac_client_mysql_la_LDFLAGS = \ + -version-info 0:0:0 \ + @PTHREAD_LIBS@ \ + @MARIADB_LIBS@ diff --git a/src/protocols/mysql/client.c b/src/protocols/mysql/client.c new file mode 100644 index 000000000..52a0ec11a --- /dev/null +++ b/src/protocols/mysql/client.c @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "mysql-driver.h" + +#include +#include +#include +#include +#include + +#include + +/** + * The TCP port used by MySQL servers, if no other port is specified. + */ +#define GUAC_MYSQL_DEFAULT_PORT 3306 + +/** + * All arguments accepted by the MySQL protocol plugin: the arguments + * common to all database shells, followed by the MySQL-specific TLS + * arguments. + */ +static const char* GUAC_MYSQL_CLIENT_ARGS[] = { + GUAC_DBSHELL_COMMON_ARGS, + "ssl-mode", + "ssl-ca-file", + NULL +}; + +/** + * The indices of the MySQL-specific arguments, following the common + * database shell arguments. + */ +enum GUAC_MYSQL_ARGS_IDX { + + /** + * The SSL/TLS behavior requested for the connection, one of + * "disabled", "preferred", "required", "verify-ca", or + * "verify-identity". Optional. + */ + IDX_SSL_MODE = GUAC_DBSHELL_COMMON_ARG_COUNT, + + /** + * The path, on the server hosting guacd, to the certificate authority + * file to verify the database server certificate against. Optional. + */ + IDX_SSL_CA_FILE, + + GUAC_MYSQL_ARGS_COUNT + +} ; + +/** + * Parses the MySQL-specific arguments into a newly-allocated + * guac_mysql_extra_settings structure. This implements + * guac_dbshell_plugin.parse_extra_args. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * A newly-allocated guac_mysql_extra_settings structure. + */ +static void* guac_mysql_parse_extra_args(guac_user* user, int argc, + const char** argv) { + + guac_mysql_extra_settings* extra = + guac_mem_zalloc(sizeof(guac_mysql_extra_settings)); + + extra->ssl_mode = + guac_user_parse_args_string(user, GUAC_MYSQL_CLIENT_ARGS, argv, + IDX_SSL_MODE, NULL); + + extra->ssl_ca_file = + guac_user_parse_args_string(user, GUAC_MYSQL_CLIENT_ARGS, argv, + IDX_SSL_CA_FILE, NULL); + + return extra; + +} + +/** + * Frees the given guac_mysql_extra_settings structure. This implements + * guac_dbshell_plugin.free_extra_args. + * + * @param data + * The structure to free. + */ +static void guac_mysql_free_extra_args(void* data) { + + guac_mysql_extra_settings* extra = (guac_mysql_extra_settings*) data; + if (extra == NULL) + return; + + guac_mem_free(extra->ssl_mode); + guac_mem_free(extra->ssl_ca_file); + guac_mem_free(extra); + +} + +/** + * The plugin definition of the MySQL protocol. + */ +static const guac_dbshell_plugin guac_mysql_plugin = { + .driver = &guac_mysql_driver, + .args = GUAC_MYSQL_CLIENT_ARGS, + .argc = GUAC_MYSQL_ARGS_COUNT, + .default_port = GUAC_MYSQL_DEFAULT_PORT, + .parse_extra_args = guac_mysql_parse_extra_args, + .free_extra_args = guac_mysql_free_extra_args +}; + +int guac_client_init(guac_client* client) { + return guac_dbshell_client_init(client, &guac_mysql_plugin); +} diff --git a/src/protocols/mysql/mysql-driver.h b/src/protocols/mysql/mysql-driver.h new file mode 100644 index 000000000..2981d6a90 --- /dev/null +++ b/src/protocols/mysql/mysql-driver.h @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_MYSQL_DRIVER_H +#define GUAC_MYSQL_DRIVER_H + +/** + * Declarations for the MySQL database shell driver, which communicates + * with MySQL and MariaDB servers via MariaDB Connector/C. + * + * @file mysql-driver.h + */ + +#include "config.h" + +#include +#include + +#include + +#include + +/** + * The database-specific settings of a MySQL connection, parsed from the + * arguments following the common database shell arguments. + */ +typedef struct guac_mysql_extra_settings { + + /** + * The SSL/TLS behavior requested for the connection, one of + * "disabled", "preferred", "required", "verify-ca", or + * "verify-identity", or NULL if not specified. + */ + char* ssl_mode; + + /** + * The path, on the server hosting guacd, to the certificate authority + * file to verify the database server certificate against, or NULL if + * not specified. + */ + char* ssl_ca_file; + +} guac_mysql_extra_settings; + +/** + * The per-connection data of the MySQL driver. + */ +typedef struct guac_mysql_data { + + /** + * The connection to the MySQL server. + */ + MYSQL* mysql; + + /** + * The server-side thread ID of the connection, as used by KILL QUERY + * to interrupt a running statement. + */ + unsigned long thread_id; + +} guac_mysql_data; + +/** + * The database shell driver implementing the MySQL protocol. + */ +extern const guac_dbshell_driver guac_mysql_driver; + +#endif diff --git a/src/protocols/mysql/mysql.c b/src/protocols/mysql/mysql.c new file mode 100644 index 000000000..fda6e4b17 --- /dev/null +++ b/src/protocols/mysql/mysql.c @@ -0,0 +1,509 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "mysql-driver.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +/** + * The number of milliseconds to wait for socket activity before rechecking + * whether the session is still running, while a statement is executing. + */ +#define GUAC_MYSQL_POLL_INTERVAL 500 + +/** + * The number of seconds to wait for the secondary connection used to + * deliver KILL QUERY requests. + */ +#define GUAC_MYSQL_CANCEL_TIMEOUT 5 + +/** + * MySQL client error codes denoting loss of the server connection. + * + * @param errnum + * The MySQL client error code to test. + * + * @return + * Non-zero if the given error code denotes loss of the server + * connection, zero otherwise. + */ +static int guac_mysql_is_connection_error(unsigned int errnum) { + return errnum == CR_SERVER_GONE_ERROR + || errnum == CR_SERVER_LOST; +} + +/** + * Waits for the socket activity requested by the MariaDB non-blocking API, + * rechecking at a fixed interval whether the session's client is still + * running so that a dead or unresponsive database server can never hang + * session teardown. + * + * @param session + * The session whose statement is executing. + * + * @param mysql + * The connection being waited on. + * + * @param status + * The combination of MYSQL_WAIT_* flags requested by the non-blocking + * API. + * + * @return + * The combination of MYSQL_WAIT_* flags describing the activity which + * occurred, or -1 if the client is no longer running and the operation + * should be abandoned. + */ +static int guac_mysql_wait(guac_dbshell_session* session, MYSQL* mysql, + int status) { + + while (session->client->state == GUAC_CLIENT_RUNNING) { + + struct pollfd pfd = { + .fd = mysql_get_socket(mysql), + .events = 0 + }; + + if (status & MYSQL_WAIT_READ) + pfd.events |= POLLIN; + + if (status & MYSQL_WAIT_WRITE) + pfd.events |= POLLOUT; + + if (status & MYSQL_WAIT_EXCEPT) + pfd.events |= POLLPRI; + + int result = poll(&pfd, 1, GUAC_MYSQL_POLL_INTERVAL); + + /* Poll errors other than interruption are fatal */ + if (result < 0 && errno != EINTR) + return -1; + + if (result > 0) { + + int occurred = 0; + + if (pfd.revents & POLLIN) + occurred |= MYSQL_WAIT_READ; + + if (pfd.revents & POLLOUT) + occurred |= MYSQL_WAIT_WRITE; + + if (pfd.revents & (POLLPRI | POLLERR | POLLHUP)) + occurred |= MYSQL_WAIT_EXCEPT; + + return occurred; + + } + + /* No activity yet; recheck client state and continue waiting */ + + } + + return -1; + +} + +/** + * Renders the current result set of the given connection to the session's + * terminal as a table. + * + * @param session + * The session to render to. + * + * @param result + * The result set to render. + * + * @return + * The number of rows rendered. + */ +static unsigned long guac_mysql_render_result(guac_dbshell_session* session, + MYSQL_RES* result) { + + unsigned int num_fields = mysql_num_fields(result); + MYSQL_FIELD* fields = mysql_fetch_fields(result); + + guac_dbshell_table* table = guac_dbshell_table_begin(session->term, + num_fields); + if (table == NULL) + return 0; + + /* Assign column headers, right-aligning numeric columns */ + for (unsigned int i = 0; i < num_fields; i++) + guac_dbshell_table_set_header(table, i, fields[i].name, + IS_NUM(fields[i].type)); + + /* Render all rows */ + MYSQL_ROW row; + while ((row = mysql_fetch_row(result)) != NULL) + guac_dbshell_table_add_row(table, (const char**) row); + + return guac_dbshell_table_end(table); + +} + +/** + * Renders the MySQL error which just occurred on the given connection to + * the session's terminal, in the style of the mysql CLI. + * + * @param session + * The session to render to. + * + * @param mysql + * The connection the error occurred on. + */ +static void guac_mysql_print_error(guac_dbshell_session* session, + MYSQL* mysql) { + guac_dbshell_println(session, "ERROR %u (%s): %s", mysql_errno(mysql), + mysql_sqlstate(mysql), mysql_error(mysql)); +} + +/** + * Applies the SSL/TLS settings requested by the connection arguments to + * the given MySQL connection handle prior to connecting. + * + * @param mysql + * The connection handle to configure. + * + * @param extra + * The database-specific settings of the connection. + */ +static void guac_mysql_apply_ssl(MYSQL* mysql, + guac_mysql_extra_settings* extra) { + + /* Apply certificate authority, if given */ + if (extra->ssl_ca_file != NULL) + mysql_ssl_set(mysql, NULL, NULL, extra->ssl_ca_file, NULL, NULL); + + if (extra->ssl_mode == NULL) + return; + + /* Require encryption for all modes except "disabled" and + * "preferred" */ + my_bool enforce = strcmp(extra->ssl_mode, "required") == 0 + || strcmp(extra->ssl_mode, "verify-ca") == 0 + || strcmp(extra->ssl_mode, "verify-identity") == 0; + + if (enforce) + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &enforce); + + /* Verify the server certificate for the verification modes */ + my_bool verify = strcmp(extra->ssl_mode, "verify-ca") == 0 + || strcmp(extra->ssl_mode, "verify-identity") == 0; + + if (verify) + mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &verify); + +} + +/** + * Establishes the connection to the MySQL server described by the settings + * of the given session. This handler implements + * guac_dbshell_driver.connect_handler. + * + * @param session + * The session on whose behalf the connection is being established. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guac_mysql_connect(guac_dbshell_session* session) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + + guac_dbshell_client* dbshell_client = + (guac_dbshell_client*) session->client->data; + guac_mysql_extra_settings* extra = + (guac_mysql_extra_settings*) dbshell_client->extra_settings; + + /* A username is always meaningful to MySQL; prompt if missing */ + guac_dbshell_prompt_credentials(session, true, false); + + /* Attempt connection, prompting for a password and retrying once (on + * a fresh handle) if access is denied without one */ + MYSQL* mysql; + for (;;) { + + mysql = mysql_init(NULL); + if (mysql == NULL) { + guac_dbshell_println(session, "ERROR: Unable to initialize " + "the MySQL client library."); + return 1; + } + + /* Enable the non-blocking API for all subsequent operations */ + mysql_options(mysql, MYSQL_OPT_NONBLOCK, 0); + + /* Bound the connection attempt */ + unsigned int timeout = settings->timeout; + mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout); + + /* Never allow the server to read files local to guacd */ + unsigned int local_infile = 0; + mysql_options(mysql, MYSQL_OPT_LOCAL_INFILE, &local_infile); + + guac_mysql_apply_ssl(mysql, extra); + + if (mysql_real_connect(mysql, settings->hostname, + settings->username, settings->password, + settings->database, settings->port, NULL, + CLIENT_MULTI_RESULTS) != NULL) + break; + + /* Prompt for the missing password and retry on access denied */ + if (mysql_errno(mysql) == ER_ACCESS_DENIED_ERROR + && settings->password == NULL) { + mysql_close(mysql); + guac_dbshell_prompt_credentials(session, false, true); + continue; + } + + guac_mysql_print_error(session, mysql); + mysql_close(mysql); + return 1; + + } + + /* All terminal I/O is UTF-8 */ + if (mysql_set_character_set(mysql, "utf8mb4")) + mysql_set_character_set(mysql, "utf8"); + + guac_mysql_data* data = guac_mem_zalloc(sizeof(guac_mysql_data)); + data->mysql = mysql; + data->thread_id = mysql_thread_id(mysql); + session->driver_data = data; + + /* Greet the user in the style of the mysql CLI */ + guac_dbshell_println(session, "Connected to %s (server version %s). " + "Statements end with ';'. Type \\h for help.", + mysql_get_host_info(mysql), mysql_get_server_info(mysql)); + guac_dbshell_println(session, ""); + + return 0; + +} + +/** + * Closes the connection to the MySQL server. This handler implements + * guac_dbshell_driver.disconnect_handler. + * + * @param session + * The session whose connection should be closed. + */ +static void guac_mysql_disconnect(guac_dbshell_session* session) { + + guac_mysql_data* data = (guac_mysql_data*) session->driver_data; + if (data == NULL) + return; + + mysql_close(data->mysql); + guac_mem_free(data); + session->driver_data = NULL; + +} + +/** + * Executes the given statement against the MySQL server, rendering all + * result sets and errors to the session's terminal. This handler + * implements guac_dbshell_driver.execute_handler. + * + * @param session + * The session on whose behalf the statement is being executed. + * + * @param statement + * The statement to execute. + * + * @return + * GUAC_DBSHELL_EXECUTE_OK if the session may continue, or + * GUAC_DBSHELL_EXECUTE_LOST if the connection has been lost. + */ +static int guac_mysql_execute(guac_dbshell_session* session, + const char* statement) { + + guac_mysql_data* data = (guac_mysql_data*) session->driver_data; + MYSQL* mysql = data->mysql; + + guac_timestamp started = guac_timestamp_current(); + + /* Send the statement, waiting via the non-blocking API so that the + * wait can be abandoned if the session ends */ + int error; + int status = mysql_real_query_start(&error, mysql, statement, + strlen(statement)); + + while (status) { + + status = guac_mysql_wait(session, mysql, status); + if (status < 0) + return GUAC_DBSHELL_EXECUTE_LOST; + + status = mysql_real_query_cont(&error, mysql, status); + + } + + if (error) { + + guac_mysql_print_error(session, mysql); + + if (guac_mysql_is_connection_error(mysql_errno(mysql))) + return GUAC_DBSHELL_EXECUTE_LOST; + + return GUAC_DBSHELL_EXECUTE_OK; + + } + + /* Process all result sets produced by the statement */ + for (;;) { + + /* Retrieve the next result set */ + MYSQL_RES* result; + status = mysql_store_result_start(&result, mysql); + + while (status) { + + status = guac_mysql_wait(session, mysql, status); + if (status < 0) + return GUAC_DBSHELL_EXECUTE_LOST; + + status = mysql_store_result_cont(&result, mysql, status); + + } + + long millis = guac_timestamp_current() - started; + + /* Render the rows of the result set, if one was produced */ + if (result != NULL) { + unsigned long rows = guac_mysql_render_result(session, result); + mysql_free_result(result); + guac_dbshell_print_summary(session, rows, 0, millis); + } + + /* Otherwise report either the affected row count or the error */ + else if (mysql_field_count(mysql) == 0) { + guac_dbshell_print_summary(session, + (unsigned long) mysql_affected_rows(mysql), 1, millis); + } + + else { + + guac_mysql_print_error(session, mysql); + + if (guac_mysql_is_connection_error(mysql_errno(mysql))) + return GUAC_DBSHELL_EXECUTE_LOST; + + return GUAC_DBSHELL_EXECUTE_OK; + + } + + /* Advance to the next result set, if any */ + int next; + status = mysql_next_result_start(&next, mysql); + + while (status) { + + status = guac_mysql_wait(session, mysql, status); + if (status < 0) + return GUAC_DBSHELL_EXECUTE_LOST; + + status = mysql_next_result_cont(&next, mysql, status); + + } + + /* No more result sets */ + if (next != 0) + break; + + } + + guac_dbshell_println(session, ""); + return GUAC_DBSHELL_EXECUTE_OK; + +} + +/** + * Interrupts the statement currently executing on the given session by + * issuing KILL QUERY over a separate, short-lived connection, exactly as + * the mysql CLI does. This handler implements + * guac_dbshell_driver.cancel_handler and is invoked from a thread other + * than the session thread. + * + * @param session + * The session whose current statement should be interrupted. + */ +static void guac_mysql_cancel(guac_dbshell_session* session) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + guac_mysql_data* data = (guac_mysql_data*) session->driver_data; + + if (data == NULL) + return; + + MYSQL* kill_connection = mysql_init(NULL); + if (kill_connection == NULL) + return; + + unsigned int timeout = GUAC_MYSQL_CANCEL_TIMEOUT; + mysql_options(kill_connection, MYSQL_OPT_CONNECT_TIMEOUT, &timeout); + + /* Deliver KILL QUERY for the session's server-side thread */ + if (mysql_real_connect(kill_connection, settings->hostname, + settings->username, settings->password, NULL, + settings->port, NULL, 0) != NULL) { + + char kill_query[64]; + snprintf(kill_query, sizeof(kill_query), "KILL QUERY %lu", + data->thread_id); + mysql_real_query(kill_connection, kill_query, strlen(kill_query)); + + } + + mysql_close(kill_connection); + +} + +const guac_dbshell_driver guac_mysql_driver = { + + .name = "mysql", + .dialect = GUAC_DBSHELL_DIALECT_MYSQL, + + .connect_handler = guac_mysql_connect, + .disconnect_handler = guac_mysql_disconnect, + .execute_handler = guac_mysql_execute, + .cancel_handler = guac_mysql_cancel + +}; diff --git a/src/protocols/oracle/Makefile.am b/src/protocols/oracle/Makefile.am new file mode 100644 index 000000000..958495785 --- /dev/null +++ b/src/protocols/oracle/Makefile.am @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +lib_LTLIBRARIES = libguac-client-oracle.la + +libguac_client_oracle_la_SOURCES = \ + client.c \ + oracle.c + +noinst_HEADERS = \ + oracle.h + +libguac_client_oracle_la_CFLAGS = \ + -Werror -Wall -Iinclude \ + @COMMON_INCLUDE@ \ + @DBSHELL_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ \ + @ORACLE_CFLAGS@ + +libguac_client_oracle_la_LIBADD = \ + @COMMON_LTLIB@ \ + @DBSHELL_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + @TERMINAL_LTLIB@ + +libguac_client_oracle_la_LDFLAGS = \ + -version-info 0:0:0 \ + @PTHREAD_LIBS@ \ + @ORACLE_LIBS@ diff --git a/src/protocols/oracle/client.c b/src/protocols/oracle/client.c new file mode 100644 index 000000000..901193a76 --- /dev/null +++ b/src/protocols/oracle/client.c @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "oracle.h" + +#include +#include +#include +#include +#include + +#include + +/** + * The TCP port used by Oracle Database listeners, if no other port is + * specified. + */ +#define GUAC_ORACLE_DEFAULT_PORT 1521 + +/** + * All arguments accepted by the Oracle Database protocol plugin: the + * arguments common to all database shells, followed by the + * Oracle-specific arguments. + */ +static const char* GUAC_ORACLE_CLIENT_ARGS[] = { + GUAC_DBSHELL_COMMON_ARGS, + "service-name", + NULL +}; + +/** + * The indices of the Oracle-specific arguments, following the common + * database shell arguments. + */ +enum GUAC_ORACLE_ARGS_IDX { + + /** + * The service name of the database to connect to. Required. + */ + IDX_SERVICE_NAME = GUAC_DBSHELL_COMMON_ARG_COUNT, + + GUAC_ORACLE_ARGS_COUNT + +} ; + +/** + * Parses the Oracle-specific arguments into a newly-allocated + * guac_oracle_extra_settings structure. This implements + * guac_dbshell_plugin.parse_extra_args. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * A newly-allocated guac_oracle_extra_settings structure. + */ +static void* guac_oracle_parse_extra_args(guac_user* user, int argc, + const char** argv) { + + guac_oracle_extra_settings* extra = + guac_mem_zalloc(sizeof(guac_oracle_extra_settings)); + + extra->service_name = + guac_user_parse_args_string(user, GUAC_ORACLE_CLIENT_ARGS, argv, + IDX_SERVICE_NAME, NULL); + + return extra; + +} + +/** + * Frees the given guac_oracle_extra_settings structure. This implements + * guac_dbshell_plugin.free_extra_args. + * + * @param data + * The structure to free. + */ +static void guac_oracle_free_extra_args(void* data) { + + guac_oracle_extra_settings* extra = (guac_oracle_extra_settings*) data; + if (extra == NULL) + return; + + guac_mem_free(extra->service_name); + guac_mem_free(extra); + +} + +/** + * The plugin definition of the Oracle Database protocol. + */ +static const guac_dbshell_plugin guac_oracle_plugin = { + .driver = &guac_oracle_driver, + .args = GUAC_ORACLE_CLIENT_ARGS, + .argc = GUAC_ORACLE_ARGS_COUNT, + .default_port = GUAC_ORACLE_DEFAULT_PORT, + .parse_extra_args = guac_oracle_parse_extra_args, + .free_extra_args = guac_oracle_free_extra_args +}; + +int guac_client_init(guac_client* client) { + return guac_dbshell_client_init(client, &guac_oracle_plugin); +} diff --git a/src/protocols/oracle/oracle.c b/src/protocols/oracle/oracle.c new file mode 100644 index 000000000..ca5178157 --- /dev/null +++ b/src/protocols/oracle/oracle.c @@ -0,0 +1,518 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "oracle.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +/** + * The OCI character set ID of AL32UTF8, the Oracle character set + * corresponding to UTF-8. + */ +#define GUAC_ORACLE_CHARSET_AL32UTF8 873 + +/** + * The maximum number of bytes of a single column value fetched from the + * server, including the null terminator. Longer values are truncated by + * the client library. + */ +#define GUAC_ORACLE_MAX_VALUE_LENGTH 4000 + +/** + * The maximum length of an EZConnect connection string, in bytes. + */ +#define GUAC_ORACLE_MAX_CONNECT_LENGTH 1024 + +/** + * The maximum length of a column name, in bytes, including the null + * terminator. + */ +#define GUAC_ORACLE_MAX_COLUMN_NAME 256 + +/** + * Retrieves and renders the details of the most recent OCI error to the + * session's terminal, returning the Oracle error code. + * + * @param session + * The session to render to. + * + * @param error + * The OCI error handle the failed call was invoked with. + * + * @return + * The Oracle error code (the numeric portion of "ORA-nnnnn"), or zero + * if no details are available. + */ +static sb4 guac_oracle_print_error(guac_dbshell_session* session, + OCIError* error) { + + text message[512] = { 0 }; + sb4 code = 0; + + if (OCIErrorGet(error, 1, NULL, &code, message, sizeof(message), + OCI_HTYPE_ERROR) == OCI_SUCCESS) { + + /* Trim the trailing newline of the message, if any */ + int length = strlen((char*) message); + while (length > 0 && (message[length - 1] == '\n' + || message[length - 1] == '\r')) + message[--length] = '\0'; + + guac_dbshell_println(session, "%s", message); + + } + else + guac_dbshell_println(session, "ERROR: (no details available)"); + + return code; + +} + +/** + * Returns whether the given Oracle error code denotes loss of the server + * connection. + * + * @param code + * The Oracle error code to test. + * + * @return + * Non-zero if the given error code denotes loss of the server + * connection, zero otherwise. + */ +static int guac_oracle_is_connection_error(sb4 code) { + + /* ORA-03113 (end-of-file on channel), ORA-03114 (not connected), + * ORA-03135 (connection lost contact), ORA-12170 (connect timeout), + * and ORA-12541 (no listener) all denote a dead connection */ + return code == 3113 || code == 3114 || code == 3135 + || code == 12170 || code == 12541; + +} + +/** + * Returns whether values of the given Oracle internal datatype are numeric + * and should be right-aligned when rendered. + * + * @param type + * The internal datatype code, as read from OCI_ATTR_DATA_TYPE. + * + * @return + * Non-zero if the datatype is numeric, zero otherwise. + */ +static int guac_oracle_is_numeric(ub2 type) { + + /* NUMBER, BINARY_FLOAT, and BINARY_DOUBLE respectively */ + return type == SQLT_NUM || type == 100 || type == 101; + +} + +/** + * Renders the result set of the given executed SELECT statement to the + * session's terminal as a table. + * + * @param session + * The session to render to. + * + * @param data + * The Oracle connection data of the session. + * + * @param statement + * The executed statement handle. + * + * @return + * The number of rows rendered, or -1 if fetching failed (the error + * having been rendered). + */ +static long guac_oracle_render_rows(guac_dbshell_session* session, + guac_oracle_data* data, OCIStmt* statement) { + + /* Determine the number of columns */ + ub4 num_columns = 0; + if (OCIAttrGet(statement, OCI_HTYPE_STMT, &num_columns, NULL, + OCI_ATTR_PARAM_COUNT, data->error) != OCI_SUCCESS + || num_columns == 0) + return -1; + + guac_dbshell_table* table = guac_dbshell_table_begin(session->term, + num_columns); + + /* Per-column fetch buffers and NULL indicators */ + char** values = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(char*))); + sb2* indicators = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(sb2))); + + /* Describe and define each column as null-terminated text */ + for (ub4 i = 0; i < num_columns; i++) { + + char name[GUAC_ORACLE_MAX_COLUMN_NAME] = { 0 }; + ub2 type = 0; + + OCIParam* column = NULL; + if (OCIParamGet(statement, OCI_HTYPE_STMT, data->error, + (void**) &column, i + 1) == OCI_SUCCESS) { + + text* column_name = NULL; + ub4 name_length = 0; + + if (OCIAttrGet(column, OCI_DTYPE_PARAM, &column_name, + &name_length, OCI_ATTR_NAME, data->error) + == OCI_SUCCESS && column_name != NULL) { + + if (name_length > sizeof(name) - 1) + name_length = sizeof(name) - 1; + + memcpy(name, column_name, name_length); + + } + + OCIAttrGet(column, OCI_DTYPE_PARAM, &type, NULL, + OCI_ATTR_DATA_TYPE, data->error); + + OCIDescriptorFree(column, OCI_DTYPE_PARAM); + + } + + guac_dbshell_table_set_header(table, i, name, + guac_oracle_is_numeric(type)); + + values[i] = guac_mem_alloc(GUAC_ORACLE_MAX_VALUE_LENGTH); + + OCIDefine* define = NULL; + OCIDefineByPos(statement, &define, data->error, i + 1, values[i], + GUAC_ORACLE_MAX_VALUE_LENGTH, SQLT_STR, &indicators[i], + NULL, NULL, OCI_DEFAULT); + + } + + /* Fetch and render all rows */ + long rows = 0; + bool failed = false; + + const char** row = guac_mem_zalloc(guac_mem_ckd_mul_or_die(num_columns, + sizeof(char*))); + + for (;;) { + + sword status = OCIStmtFetch2(statement, data->error, 1, + OCI_FETCH_NEXT, 0, OCI_DEFAULT); + + if (status == OCI_NO_DATA) + break; + + if (status != OCI_SUCCESS && status != OCI_SUCCESS_WITH_INFO) { + failed = true; + break; + } + + for (ub4 i = 0; i < num_columns; i++) + row[i] = (indicators[i] == -1) ? NULL : values[i]; + + guac_dbshell_table_add_row(table, row); + rows++; + + } + + guac_dbshell_table_end(table); + + for (ub4 i = 0; i < num_columns; i++) + guac_mem_free(values[i]); + guac_mem_free(values); + guac_mem_free(indicators); + guac_mem_free(row); + + if (failed) { + guac_oracle_print_error(session, data->error); + return -1; + } + + return rows; + +} + +/** + * Establishes the connection to the Oracle Database server described by + * the settings of the given session. This handler implements + * guac_dbshell_driver.connect_handler. + * + * @param session + * The session on whose behalf the connection is being established. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guac_oracle_connect(guac_dbshell_session* session) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + + guac_dbshell_client* dbshell_client = + (guac_dbshell_client*) session->client->data; + guac_oracle_extra_settings* extra = + (guac_oracle_extra_settings*) dbshell_client->extra_settings; + + /* The service name is required to address the database */ + if (extra->service_name == NULL) { + guac_dbshell_println(session, "ERROR: The \"service-name\" " + "parameter is required for Oracle connections."); + return 1; + } + + /* Oracle authentication always involves a username and password; + * prompt for whichever is missing */ + guac_dbshell_prompt_credentials(session, true, true); + + /* Create a threaded, UTF-8 environment */ + OCIEnv* environment = NULL; + if (OCIEnvNlsCreate(&environment, OCI_THREADED, NULL, NULL, NULL, + NULL, 0, NULL, GUAC_ORACLE_CHARSET_AL32UTF8, + GUAC_ORACLE_CHARSET_AL32UTF8) != OCI_SUCCESS) { + guac_dbshell_println(session, "ERROR: Unable to initialize the " + "Oracle client environment."); + return 1; + } + + /* Allocate error handles (one per thread which may use OCI) */ + OCIError* error = NULL; + OCIError* cancel_error = NULL; + OCIHandleAlloc(environment, (void**) &error, OCI_HTYPE_ERROR, 0, + NULL); + OCIHandleAlloc(environment, (void**) &cancel_error, OCI_HTYPE_ERROR, + 0, NULL); + + if (error == NULL || cancel_error == NULL) { + + guac_dbshell_println(session, "ERROR: Unable to allocate Oracle " + "error handles."); + + if (error != NULL) + OCIHandleFree(error, OCI_HTYPE_ERROR); + if (cancel_error != NULL) + OCIHandleFree(cancel_error, OCI_HTYPE_ERROR); + + OCIHandleFree(environment, OCI_HTYPE_ENV); + return 1; + + } + + /* Address the database using EZConnect syntax */ + char connect_string[GUAC_ORACLE_MAX_CONNECT_LENGTH]; + snprintf(connect_string, sizeof(connect_string), "//%s:%i/%s", + settings->hostname, settings->port, extra->service_name); + + /* Connect */ + OCISvcCtx* service = NULL; + if (OCILogon2(environment, error, &service, + (const OraText*) settings->username, + strlen(settings->username), + (const OraText*) (settings->password != NULL + ? settings->password : ""), + settings->password != NULL + ? strlen(settings->password) : 0, + (const OraText*) connect_string, strlen(connect_string), + OCI_LOGON2_STMTCACHE) != OCI_SUCCESS) { + + guac_oracle_print_error(session, error); + + OCIHandleFree(error, OCI_HTYPE_ERROR); + OCIHandleFree(cancel_error, OCI_HTYPE_ERROR); + OCIHandleFree(environment, OCI_HTYPE_ENV); + return 1; + + } + + guac_oracle_data* data = guac_mem_zalloc(sizeof(guac_oracle_data)); + data->environment = environment; + data->error = error; + data->cancel_error = cancel_error; + data->service = service; + session->driver_data = data; + + guac_dbshell_println(session, "Connected to %s. SQL statements end " + "with ';'; PL/SQL blocks end with a line containing only " + "\"/\". Type \\h for help.", connect_string); + guac_dbshell_println(session, ""); + + return 0; + +} + +/** + * Closes the connection to the Oracle Database server. This handler + * implements guac_dbshell_driver.disconnect_handler. + * + * @param session + * The session whose connection should be closed. + */ +static void guac_oracle_disconnect(guac_dbshell_session* session) { + + guac_oracle_data* data = (guac_oracle_data*) session->driver_data; + if (data == NULL) + return; + + OCILogoff(data->service, data->error); + OCIHandleFree(data->error, OCI_HTYPE_ERROR); + OCIHandleFree(data->cancel_error, OCI_HTYPE_ERROR); + OCIHandleFree(data->environment, OCI_HTYPE_ENV); + + guac_mem_free(data); + session->driver_data = NULL; + +} + +/** + * Executes the given statement against the Oracle Database server, + * rendering all results and errors to the session's terminal. This + * handler implements guac_dbshell_driver.execute_handler. + * + * @param session + * The session on whose behalf the statement is being executed. + * + * @param statement_text + * The statement to execute. + * + * @return + * GUAC_DBSHELL_EXECUTE_OK if the session may continue, or + * GUAC_DBSHELL_EXECUTE_LOST if the connection has been lost. + */ +static int guac_oracle_execute(guac_dbshell_session* session, + const char* statement_text) { + + guac_oracle_data* data = (guac_oracle_data*) session->driver_data; + + guac_timestamp started = guac_timestamp_current(); + + /* Prepare the statement */ + OCIStmt* statement = NULL; + if (OCIStmtPrepare2(data->service, &statement, data->error, + (const OraText*) statement_text, strlen(statement_text), + NULL, 0, OCI_NTV_SYNTAX, OCI_DEFAULT) != OCI_SUCCESS) { + sb4 code = guac_oracle_print_error(session, data->error); + return guac_oracle_is_connection_error(code) + ? GUAC_DBSHELL_EXECUTE_LOST : GUAC_DBSHELL_EXECUTE_OK; + } + + /* Determine whether the statement is a query */ + ub2 statement_type = 0; + OCIAttrGet(statement, OCI_HTYPE_STMT, &statement_type, NULL, + OCI_ATTR_STMT_TYPE, data->error); + + int result = GUAC_DBSHELL_EXECUTE_OK; + + /* Queries execute with no prefetched iterations, then fetch */ + if (statement_type == OCI_STMT_SELECT) { + + sword status = OCIStmtExecute(data->service, statement, + data->error, 0, 0, NULL, NULL, OCI_DEFAULT); + + if (status != OCI_SUCCESS && status != OCI_SUCCESS_WITH_INFO) { + sb4 code = guac_oracle_print_error(session, data->error); + OCIReset(data->service, data->error); + if (guac_oracle_is_connection_error(code)) + result = GUAC_DBSHELL_EXECUTE_LOST; + } + + else { + + long rows = guac_oracle_render_rows(session, data, statement); + if (rows >= 0) + guac_dbshell_print_summary(session, (unsigned long) rows, + 0, guac_timestamp_current() - started); + + } + + } + + /* All other statements execute exactly once and commit on success */ + else { + + sword status = OCIStmtExecute(data->service, statement, + data->error, 1, 0, NULL, NULL, OCI_COMMIT_ON_SUCCESS); + + if (status != OCI_SUCCESS && status != OCI_SUCCESS_WITH_INFO) { + sb4 code = guac_oracle_print_error(session, data->error); + OCIReset(data->service, data->error); + if (guac_oracle_is_connection_error(code)) + result = GUAC_DBSHELL_EXECUTE_LOST; + } + + else { + + ub4 affected = 0; + OCIAttrGet(statement, OCI_HTYPE_STMT, &affected, NULL, + OCI_ATTR_ROW_COUNT, data->error); + + guac_dbshell_print_summary(session, affected, 1, + guac_timestamp_current() - started); + + } + + } + + OCIStmtRelease(statement, data->error, NULL, 0, OCI_DEFAULT); + + guac_dbshell_println(session, ""); + return result; + +} + +/** + * Interrupts the statement currently executing on the given session using + * OCIBreak(), which is safe to invoke from a thread other than the one + * executing the statement. This handler implements + * guac_dbshell_driver.cancel_handler. + * + * @param session + * The session whose current statement should be interrupted. + */ +static void guac_oracle_cancel(guac_dbshell_session* session) { + + guac_oracle_data* data = (guac_oracle_data*) session->driver_data; + if (data == NULL) + return; + + OCIBreak(data->service, data->cancel_error); + +} + +const guac_dbshell_driver guac_oracle_driver = { + + .name = "oracle", + .dialect = GUAC_DBSHELL_DIALECT_ORACLE, + + .connect_handler = guac_oracle_connect, + .disconnect_handler = guac_oracle_disconnect, + .execute_handler = guac_oracle_execute, + .cancel_handler = guac_oracle_cancel + +}; diff --git a/src/protocols/oracle/oracle.h b/src/protocols/oracle/oracle.h new file mode 100644 index 000000000..d9fbf828f --- /dev/null +++ b/src/protocols/oracle/oracle.h @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_ORACLE_H +#define GUAC_ORACLE_H + +/** + * Declarations for the Oracle Database shell driver, which communicates + * with Oracle Database servers via the Oracle Call Interface (OCI) of the + * Oracle Instant Client. + * + * @file oracle.h + */ + +#include "config.h" + +#include +#include + +#include + +/** + * The database-specific settings of an Oracle Database connection, parsed + * from the arguments following the common database shell arguments. + */ +typedef struct guac_oracle_extra_settings { + + /** + * The service name of the database to connect to, as used within an + * EZConnect connection string ("//host:port/service_name"). + */ + char* service_name; + +} guac_oracle_extra_settings; + +/** + * The per-connection data of the Oracle Database driver. + */ +typedef struct guac_oracle_data { + + /** + * The OCI environment handle. + */ + OCIEnv* environment; + + /** + * The OCI error handle used by the session thread. + */ + OCIError* error; + + /** + * The OCI error handle used by cancellation requests, which arrive on + * a different thread than the session thread. + */ + OCIError* cancel_error; + + /** + * The OCI service context of the established connection. + */ + OCISvcCtx* service; + +} guac_oracle_data; + +/** + * The database shell driver implementing the Oracle Database protocol. + */ +extern const guac_dbshell_driver guac_oracle_driver; + +#endif diff --git a/src/protocols/postgresql/Makefile.am b/src/protocols/postgresql/Makefile.am new file mode 100644 index 000000000..b23403fac --- /dev/null +++ b/src/protocols/postgresql/Makefile.am @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim +# into Makefile.in. Though the build system (GNU Autotools) automatically adds +# its own license boilerplate to the generated Makefile.in, that boilerplate +# does not apply to the transcluded portions of Makefile.am which are licensed +# to you by the ASF under the Apache License, Version 2.0, as described above. +# + +AUTOMAKE_OPTIONS = foreign +ACLOCAL_AMFLAGS = -I m4 + +lib_LTLIBRARIES = libguac-client-postgresql.la + +libguac_client_postgresql_la_SOURCES = \ + client.c \ + postgresql.c + +noinst_HEADERS = \ + postgresql.h + +libguac_client_postgresql_la_CFLAGS = \ + -Werror -Wall -Iinclude \ + @COMMON_INCLUDE@ \ + @DBSHELL_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @TERMINAL_INCLUDE@ \ + @LIBPQ_CFLAGS@ + +libguac_client_postgresql_la_LIBADD = \ + @COMMON_LTLIB@ \ + @DBSHELL_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + @TERMINAL_LTLIB@ + +libguac_client_postgresql_la_LDFLAGS = \ + -version-info 0:0:0 \ + @PTHREAD_LIBS@ \ + @LIBPQ_LIBS@ diff --git a/src/protocols/postgresql/client.c b/src/protocols/postgresql/client.c new file mode 100644 index 000000000..d589668d0 --- /dev/null +++ b/src/protocols/postgresql/client.c @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "postgresql.h" + +#include +#include +#include +#include +#include + +#include + +/** + * The TCP port used by PostgreSQL servers, if no other port is specified. + */ +#define GUAC_POSTGRESQL_DEFAULT_PORT 5432 + +/** + * All arguments accepted by the PostgreSQL protocol plugin: the arguments + * common to all database shells, followed by the PostgreSQL-specific TLS + * arguments. + */ +static const char* GUAC_POSTGRESQL_CLIENT_ARGS[] = { + GUAC_DBSHELL_COMMON_ARGS, + "ssl-mode", + "ssl-ca-file", + NULL +}; + +/** + * The indices of the PostgreSQL-specific arguments, following the common + * database shell arguments. + */ +enum GUAC_POSTGRESQL_ARGS_IDX { + + /** + * The libpq sslmode of the connection, one of "disable", "allow", + * "prefer", "require", "verify-ca", or "verify-full". Optional. + */ + IDX_SSL_MODE = GUAC_DBSHELL_COMMON_ARG_COUNT, + + /** + * The path, on the server hosting guacd, to the certificate authority + * file to verify the database server certificate against. Optional. + */ + IDX_SSL_CA_FILE, + + GUAC_POSTGRESQL_ARGS_COUNT + +} ; + +/** + * Parses the PostgreSQL-specific arguments into a newly-allocated + * guac_postgresql_extra_settings structure. This implements + * guac_dbshell_plugin.parse_extra_args. + * + * @param user + * The user who submitted the given arguments while joining the + * connection. + * + * @param argc + * The number of arguments within the argv array. + * + * @param argv + * The values of all arguments provided by the user. + * + * @return + * A newly-allocated guac_postgresql_extra_settings structure. + */ +static void* guac_postgresql_parse_extra_args(guac_user* user, int argc, + const char** argv) { + + guac_postgresql_extra_settings* extra = + guac_mem_zalloc(sizeof(guac_postgresql_extra_settings)); + + extra->ssl_mode = + guac_user_parse_args_string(user, GUAC_POSTGRESQL_CLIENT_ARGS, + argv, IDX_SSL_MODE, NULL); + + extra->ssl_ca_file = + guac_user_parse_args_string(user, GUAC_POSTGRESQL_CLIENT_ARGS, + argv, IDX_SSL_CA_FILE, NULL); + + return extra; + +} + +/** + * Frees the given guac_postgresql_extra_settings structure. This + * implements guac_dbshell_plugin.free_extra_args. + * + * @param data + * The structure to free. + */ +static void guac_postgresql_free_extra_args(void* data) { + + guac_postgresql_extra_settings* extra = + (guac_postgresql_extra_settings*) data; + if (extra == NULL) + return; + + guac_mem_free(extra->ssl_mode); + guac_mem_free(extra->ssl_ca_file); + guac_mem_free(extra); + +} + +/** + * The plugin definition of the PostgreSQL protocol. + */ +static const guac_dbshell_plugin guac_postgresql_plugin = { + .driver = &guac_postgresql_driver, + .args = GUAC_POSTGRESQL_CLIENT_ARGS, + .argc = GUAC_POSTGRESQL_ARGS_COUNT, + .default_port = GUAC_POSTGRESQL_DEFAULT_PORT, + .parse_extra_args = guac_postgresql_parse_extra_args, + .free_extra_args = guac_postgresql_free_extra_args +}; + +int guac_client_init(guac_client* client) { + return guac_dbshell_client_init(client, &guac_postgresql_plugin); +} diff --git a/src/protocols/postgresql/postgresql.c b/src/protocols/postgresql/postgresql.c new file mode 100644 index 000000000..c09eca33f --- /dev/null +++ b/src/protocols/postgresql/postgresql.c @@ -0,0 +1,511 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "config.h" + +#include "postgresql.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +/** + * The number of milliseconds to wait for socket activity before rechecking + * whether the session is still running, while a statement is executing. + */ +#define GUAC_POSTGRESQL_POLL_INTERVAL 500 + +/** + * The maximum number of connection parameters passed to + * PQconnectdbParams(), including the terminating NULL entry. + */ +#define GUAC_POSTGRESQL_MAX_PARAMS 16 + +/** + * Returns whether values of the given PostgreSQL type OID are numeric and + * should be right-aligned when rendered. + * + * @param type + * The type OID to test. + * + * @return + * Non-zero if the type is numeric, zero otherwise. + */ +static int guac_postgresql_is_numeric(Oid type) { + + /* The built-in OIDs of int8, int2, int4, oid, float4, float8, and + * numeric respectively */ + return type == 20 || type == 21 || type == 23 || type == 26 + || type == 700 || type == 701 || type == 1700; + +} + +/** + * Notice processor invoked by libpq for each notice or warning raised by + * the server, rendering the notice to the session's terminal. + * + * @param arg + * The guac_dbshell_session of the connection, as registered with + * PQsetNoticeProcessor(). + * + * @param message + * The notice message, including any trailing newline. + */ +static void guac_postgresql_notice_processor(void* arg, + const char* message) { + + guac_dbshell_session* session = (guac_dbshell_session*) arg; + + /* Trim the trailing newline supplied by libpq */ + int length = strlen(message); + while (length > 0 && (message[length - 1] == '\n' + || message[length - 1] == '\r')) + length--; + + guac_dbshell_println(session, "%.*s", length, message); + +} + +/** + * Waits for read activity on the given connection's socket, rechecking at + * a fixed interval whether the session's client is still running so that a + * dead or unresponsive database server can never hang session teardown. + * + * @param session + * The session whose statement is executing. + * + * @param connection + * The connection being waited on. + * + * @return + * Zero if read activity occurred, non-zero if the client is no longer + * running and the operation should be abandoned. + */ +static int guac_postgresql_wait(guac_dbshell_session* session, + PGconn* connection) { + + while (session->client->state == GUAC_CLIENT_RUNNING) { + + struct pollfd pfd = { + .fd = PQsocket(connection), + .events = POLLIN + }; + + int result = poll(&pfd, 1, GUAC_POSTGRESQL_POLL_INTERVAL); + + /* Poll errors other than interruption are fatal */ + if (result < 0 && errno != EINTR) + return 1; + + if (result > 0) + return 0; + + /* No activity yet; recheck client state and continue waiting */ + + } + + return 1; + +} + +/** + * Renders the given result set to the session's terminal as a table. + * + * @param session + * The session to render to. + * + * @param result + * The result set to render. + * + * @return + * The number of rows rendered. + */ +static unsigned long guac_postgresql_render_result( + guac_dbshell_session* session, PGresult* result) { + + int num_fields = PQnfields(result); + int num_rows = PQntuples(result); + + guac_dbshell_table* table = guac_dbshell_table_begin(session->term, + num_fields); + if (table == NULL) + return 0; + + /* Assign column headers, right-aligning numeric columns */ + for (int i = 0; i < num_fields; i++) + guac_dbshell_table_set_header(table, i, PQfname(result, i), + guac_postgresql_is_numeric(PQftype(result, i))); + + /* Render all rows */ + const char** values = guac_mem_alloc(guac_mem_ckd_mul_or_die( + num_fields, sizeof(char*))); + + for (int row = 0; row < num_rows; row++) { + + for (int col = 0; col < num_fields; col++) { + if (PQgetisnull(result, row, col)) + values[col] = NULL; + else + values[col] = PQgetvalue(result, row, col); + } + + guac_dbshell_table_add_row(table, values); + + } + + guac_mem_free(values); + return guac_dbshell_table_end(table); + +} + +/** + * Renders the given PostgreSQL error message to the session's terminal, + * trimming any trailing newlines. + * + * @param session + * The session to render to. + * + * @param message + * The error message to render, or NULL if no message is available. + */ +static void guac_postgresql_print_error(guac_dbshell_session* session, + const char* message) { + + if (message == NULL || *message == '\0') { + guac_dbshell_println(session, "ERROR: (no details available)"); + return; + } + + int length = strlen(message); + while (length > 0 && (message[length - 1] == '\n' + || message[length - 1] == '\r')) + length--; + + guac_dbshell_println(session, "%.*s", length, message); + +} + +/** + * Attempts a single connection to the PostgreSQL server described by the + * given settings. + * + * @param settings + * The common settings of the session. + * + * @param extra + * The PostgreSQL-specific settings of the session. + * + * @return + * The libpq connection object, which must be tested with PQstatus() + * and eventually freed with PQfinish(). + */ +static PGconn* guac_postgresql_attempt(guac_dbshell_settings* settings, + guac_postgresql_extra_settings* extra) { + + const char* keywords[GUAC_POSTGRESQL_MAX_PARAMS]; + const char* values[GUAC_POSTGRESQL_MAX_PARAMS]; + int param = 0; + + char port[GUAC_USHORT_STRING_BUFSIZE]; + guac_itoa_safe(port, sizeof(port), settings->port); + + char timeout[32]; + snprintf(timeout, sizeof(timeout), "%i", settings->timeout); + + keywords[param] = "host"; values[param++] = settings->hostname; + keywords[param] = "port"; values[param++] = port; + keywords[param] = "connect_timeout"; values[param++] = timeout; + keywords[param] = "client_encoding"; values[param++] = "UTF8"; + + if (settings->username != NULL) { + keywords[param] = "user"; + values[param++] = settings->username; + } + + if (settings->password != NULL) { + keywords[param] = "password"; + values[param++] = settings->password; + } + + if (settings->database != NULL) { + keywords[param] = "dbname"; + values[param++] = settings->database; + } + + if (extra->ssl_mode != NULL) { + keywords[param] = "sslmode"; + values[param++] = extra->ssl_mode; + } + + if (extra->ssl_ca_file != NULL) { + keywords[param] = "sslrootcert"; + values[param++] = extra->ssl_ca_file; + } + + keywords[param] = NULL; + values[param] = NULL; + + return PQconnectdbParams(keywords, values, 0); + +} + +/** + * Establishes the connection to the PostgreSQL server described by the + * settings of the given session. This handler implements + * guac_dbshell_driver.connect_handler. + * + * @param session + * The session on whose behalf the connection is being established. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guac_postgresql_connect(guac_dbshell_session* session) { + + guac_dbshell_settings* settings = + (guac_dbshell_settings*) session->settings; + + guac_dbshell_client* dbshell_client = + (guac_dbshell_client*) session->client->data; + guac_postgresql_extra_settings* extra = + (guac_postgresql_extra_settings*) dbshell_client->extra_settings; + + /* PostgreSQL requires a username (there is no guacd-side identity to + * fall back on); prompt if missing */ + guac_dbshell_prompt_credentials(session, true, false); + + /* Attempt connection, prompting for a password and retrying once if + * the server demands one, as psql does */ + PGconn* connection = guac_postgresql_attempt(settings, extra); + + if (PQstatus(connection) != CONNECTION_OK + && PQconnectionNeedsPassword(connection) + && settings->password == NULL) { + + PQfinish(connection); + guac_dbshell_prompt_credentials(session, false, true); + connection = guac_postgresql_attempt(settings, extra); + + } + + if (PQstatus(connection) != CONNECTION_OK) { + guac_postgresql_print_error(session, + PQerrorMessage(connection)); + PQfinish(connection); + return 1; + } + + guac_postgresql_data* data = + guac_mem_zalloc(sizeof(guac_postgresql_data)); + data->connection = connection; + data->cancel = PQgetCancel(connection); + session->driver_data = data; + + /* Render server notices to the terminal as they arrive */ + PQsetNoticeProcessor(connection, guac_postgresql_notice_processor, + session); + + /* Greet the user in the style of psql */ + guac_dbshell_println(session, "Connected to %s (server version %s). " + "Statements end with ';'. Type \\h for help.", + PQdb(connection), + PQparameterStatus(connection, "server_version")); + guac_dbshell_println(session, ""); + + return 0; + +} + +/** + * Closes the connection to the PostgreSQL server. This handler implements + * guac_dbshell_driver.disconnect_handler. + * + * @param session + * The session whose connection should be closed. + */ +static void guac_postgresql_disconnect(guac_dbshell_session* session) { + + guac_postgresql_data* data = + (guac_postgresql_data*) session->driver_data; + if (data == NULL) + return; + + PQfreeCancel(data->cancel); + PQfinish(data->connection); + guac_mem_free(data); + session->driver_data = NULL; + +} + +/** + * Executes the given statement against the PostgreSQL server, rendering + * all result sets and errors to the session's terminal. This handler + * implements guac_dbshell_driver.execute_handler. + * + * @param session + * The session on whose behalf the statement is being executed. + * + * @param statement + * The statement to execute. + * + * @return + * GUAC_DBSHELL_EXECUTE_OK if the session may continue, or + * GUAC_DBSHELL_EXECUTE_LOST if the connection has been lost. + */ +static int guac_postgresql_execute(guac_dbshell_session* session, + const char* statement) { + + guac_postgresql_data* data = + (guac_postgresql_data*) session->driver_data; + PGconn* connection = data->connection; + + guac_timestamp started = guac_timestamp_current(); + + /* Dispatch the statement */ + if (!PQsendQuery(connection, statement)) { + guac_postgresql_print_error(session, + PQerrorMessage(connection)); + return PQstatus(connection) == CONNECTION_OK + ? GUAC_DBSHELL_EXECUTE_OK : GUAC_DBSHELL_EXECUTE_LOST; + } + + bool abandoned = false; + + /* Process every result produced by the statement */ + for (;;) { + + /* Wait (interruptibly) until the next result is available */ + while (!abandoned && PQisBusy(connection)) { + + if (guac_postgresql_wait(session, connection) + || !PQconsumeInput(connection)) { + abandoned = true; + break; + } + + } + + /* Even once abandoned, drain any pending results so the + * connection object can be freed cleanly */ + PGresult* result = PQgetResult(connection); + if (result == NULL) + break; + + if (abandoned) { + PQclear(result); + continue; + } + + long millis = guac_timestamp_current() - started; + + switch (PQresultStatus(result)) { + + /* Render returned rows as a table */ + case PGRES_TUPLES_OK: { + unsigned long rows = guac_postgresql_render_result(session, + result); + guac_dbshell_print_summary(session, rows, 0, millis); + break; + } + + /* Report affected rows for statements returning no rows */ + case PGRES_COMMAND_OK: { + + const char* affected = PQcmdTuples(result); + if (affected != NULL && *affected != '\0') + guac_dbshell_print_summary(session, + strtoul(affected, NULL, 10), 1, millis); + else + guac_dbshell_println(session, "%s", + PQcmdStatus(result)); + + break; + } + + case PGRES_EMPTY_QUERY: + break; + + /* All other statuses are errors */ + default: + guac_postgresql_print_error(session, + PQresultErrorMessage(result)); + break; + + } + + PQclear(result); + + } + + guac_dbshell_println(session, ""); + + /* The connection may have been lost while executing */ + if (abandoned || PQstatus(connection) != CONNECTION_OK) + return GUAC_DBSHELL_EXECUTE_LOST; + + return GUAC_DBSHELL_EXECUTE_OK; + +} + +/** + * Interrupts the statement currently executing on the given session using + * libpq's thread-safe cancellation interface. This handler implements + * guac_dbshell_driver.cancel_handler. + * + * @param session + * The session whose current statement should be interrupted. + */ +static void guac_postgresql_cancel(guac_dbshell_session* session) { + + guac_postgresql_data* data = + (guac_postgresql_data*) session->driver_data; + + if (data == NULL || data->cancel == NULL) + return; + + char error[256]; + PQcancel(data->cancel, error, sizeof(error)); + +} + +const guac_dbshell_driver guac_postgresql_driver = { + + .name = "postgresql", + .dialect = GUAC_DBSHELL_DIALECT_PGSQL, + + .connect_handler = guac_postgresql_connect, + .disconnect_handler = guac_postgresql_disconnect, + .execute_handler = guac_postgresql_execute, + .cancel_handler = guac_postgresql_cancel + +}; diff --git a/src/protocols/postgresql/postgresql.h b/src/protocols/postgresql/postgresql.h new file mode 100644 index 000000000..b5807f284 --- /dev/null +++ b/src/protocols/postgresql/postgresql.h @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#ifndef GUAC_POSTGRESQL_H +#define GUAC_POSTGRESQL_H + +/** + * Declarations for the PostgreSQL database shell driver, which + * communicates with PostgreSQL servers via libpq. + * + * @file postgresql.h + */ + +#include "config.h" + +#include +#include + +#include + +/** + * The database-specific settings of a PostgreSQL connection, parsed from + * the arguments following the common database shell arguments. + */ +typedef struct guac_postgresql_extra_settings { + + /** + * The libpq sslmode of the connection, one of "disable", "allow", + * "prefer", "require", "verify-ca", or "verify-full", or NULL if not + * specified. + */ + char* ssl_mode; + + /** + * The path, on the server hosting guacd, to the certificate authority + * file to verify the database server certificate against, or NULL if + * not specified. + */ + char* ssl_ca_file; + +} guac_postgresql_extra_settings; + +/** + * The per-connection data of the PostgreSQL driver. + */ +typedef struct guac_postgresql_data { + + /** + * The connection to the PostgreSQL server. + */ + PGconn* connection; + + /** + * The cancellation handle of the connection, created when the + * connection is established and usable from any thread. + */ + PGcancel* cancel; + +} guac_postgresql_data; + +/** + * The database shell driver implementing the PostgreSQL protocol. + */ +extern const guac_dbshell_driver guac_postgresql_driver; + +#endif