Merge 1.1.0 changes back to master.
diff --git a/.gitignore b/.gitignore
index e0a6d53..e716ee0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,10 @@
 *.gcov
 *.gcno
 
+# Test suite output
+*.log
+*.trs
+
 # Backup files
 *~
 
diff --git a/README-unit-testing.md b/README-unit-testing.md
index 5bed872..8b4e776 100644
--- a/README-unit-testing.md
+++ b/README-unit-testing.md
@@ -60,7 +60,7 @@
     CLEANFILES = _generated_runner.c
 
     _generated_runner.c: $(test_myproj_SOURCES)
-    	$(AM_V_GEN) $(GEN_RUNNER) $^ > $@
+    	$(AM_V_GEN) $(GEN_RUNNER) $(test_myproj_SOURCES) > $@
 
     nodist_test_libguac_SOURCES = \
         _generated_runner.c
diff --git a/configure.ac b/configure.ac
index 0acb27b..e1950ef 100644
--- a/configure.ac
+++ b/configure.ac
@@ -42,6 +42,7 @@
 
 # Source characteristics
 AC_DEFINE([_XOPEN_SOURCE], [700], [Uses X/Open and POSIX APIs])
+AC_DEFINE([__BSD_VISIBLE], [1], [Uses BSD-specific APIs (if available)])
 
 # Check for whether math library is required
 AC_CHECK_LIB([m], [cos],
@@ -120,6 +121,16 @@
                [Whether poll() is defined])],,
 	[#include <poll.h>])
 
+AC_CHECK_DECL([strlcpy],
+	[AC_DEFINE([HAVE_STRLCPY],,
+               [Whether strlcpy() is defined])],,
+	[#include <string.h>])
+
+AC_CHECK_DECL([strlcat],
+	[AC_DEFINE([HAVE_STRLCAT],,
+               [Whether strlcat() is defined])],,
+	[#include <string.h>])
+
 # Typedefs
 AC_TYPE_SIZE_T
 AC_TYPE_SSIZE_T
@@ -140,6 +151,10 @@
 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')
 
+# RDP support
+AC_SUBST([LIBGUAC_CLIENT_RDP_LTLIB],   '$(top_builddir)/src/protocols/rdp/libguac-client-rdp.la')
+AC_SUBST([LIBGUAC_CLIENT_RDP_INCLUDE], '-I$(top_srcdir)/src/protocols/rdp')
+
 # Terminal emulator
 AC_SUBST([TERMINAL_LTLIB],   '$(top_builddir)/src/terminal/libguac_terminal.la')
 AC_SUBST([TERMINAL_INCLUDE], '-I$(top_srcdir)/src/terminal $(PANGO_CFLAGS) $(PANGOCAIRO_CFLAGS) $(COMMON_INCLUDE)')
@@ -1306,6 +1321,7 @@
                  src/common/Makefile
                  src/common/tests/Makefile
                  src/common-ssh/Makefile
+                 src/common-ssh/tests/Makefile
                  src/terminal/Makefile
                  src/libguac/Makefile
                  src/libguac/tests/Makefile
@@ -1319,6 +1335,7 @@
                  src/pulse/Makefile
                  src/protocols/kubernetes/Makefile
                  src/protocols/rdp/Makefile
+                 src/protocols/rdp/tests/Makefile
                  src/protocols/ssh/Makefile
                  src/protocols/telnet/Makefile
                  src/protocols/vnc/Makefile])
diff --git a/src/common-ssh/.gitignore b/src/common-ssh/.gitignore
new file mode 100644
index 0000000..f18cde5
--- /dev/null
+++ b/src/common-ssh/.gitignore
@@ -0,0 +1,5 @@
+
+# Auto-generated test runner and binary
+_generated_runner.c
+test_common_ssh
+
diff --git a/src/common-ssh/Makefile.am b/src/common-ssh/Makefile.am
index 8116723..8402e5b 100644
--- a/src/common-ssh/Makefile.am
+++ b/src/common-ssh/Makefile.am
@@ -27,6 +27,7 @@
 ACLOCAL_AMFLAGS = -I m4
 
 noinst_LTLIBRARIES = libguac_common_ssh.la
+SUBDIRS = . tests
 
 libguac_common_ssh_la_SOURCES = \
     buffer.c                    \
diff --git a/src/common-ssh/common-ssh/sftp.h b/src/common-ssh/common-ssh/sftp.h
index 0ec2d12..eafdf21 100644
--- a/src/common-ssh/common-ssh/sftp.h
+++ b/src/common-ssh/common-ssh/sftp.h
@@ -255,5 +255,30 @@
 void guac_common_ssh_sftp_set_upload_path(
         guac_common_ssh_sftp_filesystem* filesystem, const char* path);
 
+/**
+ * Given an arbitrary absolute path, which may contain "..", ".", and
+ * backslashes, creates an equivalent absolute path which does NOT contain
+ * relative path components (".." or "."), backslashes, or empty path
+ * components. With the exception of paths referring to the root directory, the
+ * resulting path is guaranteed to not contain trailing slashes.
+ *
+ * Normalization will fail if the given path is not absolute, is too long, or
+ * contains more than GUAC_COMMON_SSH_SFTP_MAX_DEPTH path components.
+ *
+ * @param fullpath
+ *     The buffer to populate with the normalized path. The normalized path
+ *     will not contain relative path components like ".." or ".", nor will it
+ *     contain backslashes. This buffer MUST be at least
+ *     GUAC_COMMON_SSH_SFTP_MAX_PATH bytes in size.
+ *
+ * @param path
+ *     The absolute path to normalize.
+ *
+ * @return
+ *     Non-zero if normalization succeeded, zero otherwise.
+ */
+int guac_common_ssh_sftp_normalize_path(char* fullpath,
+        const char* path);
+
 #endif
 
diff --git a/src/common-ssh/sftp.c b/src/common-ssh/sftp.c
index 8a53b26..b05409b 100644
--- a/src/common-ssh/sftp.c
+++ b/src/common-ssh/sftp.c
@@ -24,6 +24,7 @@
 #include <guacamole/object.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
+#include <guacamole/string.h>
 #include <guacamole/user.h>
 #include <libssh2.h>
 
@@ -32,107 +33,70 @@
 #include <stdlib.h>
 #include <string.h>
 
-/**
- * Given an arbitrary absolute path, which may contain "..", ".", and
- * backslashes, creates an equivalent absolute path which does NOT contain
- * relative path components (".." or "."), backslashes, or empty path
- * components. With the exception of paths referring to the root directory, the
- * resulting path is guaranteed to not contain trailing slashes.
- *
- * Normalization will fail if the given path is not absolute, is too long, or
- * contains more than GUAC_COMMON_SSH_SFTP_MAX_DEPTH path components.
- *
- * @param fullpath
- *     The buffer to populate with the normalized path. The normalized path
- *     will not contain relative path components like ".." or ".", nor will it
- *     contain backslashes. This buffer MUST be at least
- *     GUAC_COMMON_SSH_SFTP_MAX_PATH bytes in size.
- *
- * @param path
- *     The absolute path to normalize.
- *
- * @return
- *     Non-zero if normalization succeeded, zero otherwise.
- */
-static int guac_common_ssh_sftp_normalize_path(char* fullpath,
+int guac_common_ssh_sftp_normalize_path(char* fullpath,
         const char* path) {
 
-    int i;
-
     int path_depth = 0;
-    char path_component_data[GUAC_COMMON_SSH_SFTP_MAX_PATH];
     const char* path_components[GUAC_COMMON_SSH_SFTP_MAX_DEPTH];
 
-    const char** current_path_component      = &(path_components[0]);
-    const char*  current_path_component_data = &(path_component_data[0]);
-
     /* If original path is not absolute, normalization fails */
     if (path[0] != '\\' && path[0] != '/')
-        return 1;
+        return 0;
 
-    /* Skip past leading slash */
-    path++;
+    /* Create scratch copy of path excluding leading slash (we will be
+     * replacing path separators with null terminators and referencing those
+     * substrings directly as path components) */
+    char path_scratch[GUAC_COMMON_SSH_SFTP_MAX_PATH - 1];
+    int length = guac_strlcpy(path_scratch, path + 1,
+            sizeof(path_scratch));
 
-    /* Copy path into component data for parsing */
-    strncpy(path_component_data, path, sizeof(path_component_data) - 1);
+    /* Fail if provided path is too long */
+    if (length >= sizeof(path_scratch))
+        return 0;
 
-    /* Find path components within path */
-    for (i = 0; i < sizeof(path_component_data) - 1; i++) {
+    /* Locate all path components within path */
+    const char* current_path_component = &(path_scratch[0]);
+    for (int i = 0; i <= length; i++) {
 
         /* If current character is a path separator, parse as component */
-        char c = path_component_data[i];
+        char c = path_scratch[i];
         if (c == '/' || c == '\\' || c == '\0') {
 
             /* Terminate current component */
-            path_component_data[i] = '\0';
+            path_scratch[i] = '\0';
 
             /* If component refers to parent, just move up in depth */
-            if (strcmp(current_path_component_data, "..") == 0) {
+            if (strcmp(current_path_component, "..") == 0) {
                 if (path_depth > 0)
                     path_depth--;
             }
 
             /* Otherwise, if component not current directory, add to list */
-            else if (strcmp(current_path_component_data,   ".") != 0
-                     && strcmp(current_path_component_data, "") != 0)
-                path_components[path_depth++] = current_path_component_data;
+            else if (strcmp(current_path_component, ".") != 0
+                    && strcmp(current_path_component, "") != 0) {
 
-            /* If end of string, stop */
-            if (c == '\0')
-                break;
+                /* Fail normalization if path is too deep */
+                if (path_depth >= GUAC_COMMON_SSH_SFTP_MAX_DEPTH)
+                    return 0;
+
+                path_components[path_depth++] = current_path_component;
+
+            }
 
             /* Update start of next component */
-            current_path_component_data = &(path_component_data[i+1]);
+            current_path_component = &(path_scratch[i+1]);
 
         } /* end if separator */
 
     } /* end for each character */
 
-    /* If no components, the path is simply root */
-    if (path_depth == 0) {
-        strcpy(fullpath, "/");
-        return 1;
-    }
+    /* Add leading slash for resulting absolute path */
+    fullpath[0] = '/';
 
-    /* Ensure last component is null-terminated */
-    path_component_data[i] = 0;
+    /* Append normalized components to path, separated by slashes */
+    guac_strljoin(fullpath + 1, path_components, path_depth,
+            "/", GUAC_COMMON_SSH_SFTP_MAX_PATH - 1);
 
-    /* Convert components back into path */
-    for (; path_depth > 0; path_depth--) {
-
-        const char* filename = *(current_path_component++);
-
-        /* Add separator */
-        *(fullpath++) = '/';
-
-        /* Copy string */
-        while (*filename != 0)
-            *(fullpath++) = *(filename++);
-
-    }
-
-    /* Terminate absolute path */
-    *(fullpath++) = 0;
     return 1;
 
 }
@@ -229,7 +193,7 @@
 static int guac_ssh_append_filename(char* fullpath, const char* path,
         const char* filename) {
 
-    int i;
+    int length;
 
     /* Disallow "." as a filename */
     if (strcmp(filename, ".") == 0)
@@ -239,49 +203,29 @@
     if (strcmp(filename, "..") == 0)
         return 0;
 
-    /* Copy path, append trailing slash */
-    for (i=0; i<GUAC_COMMON_SSH_SFTP_MAX_PATH; i++) {
-
-        /*
-         * Append trailing slash only if:
-         *  1) Trailing slash is not already present
-         *  2) Path is non-empty
-         */
-
-        char c = path[i];
-        if (c == '\0') {
-            if (i > 0 && path[i-1] != '/')
-                fullpath[i++] = '/';
-            break;
-        }
-
-        /* Copy character if not end of string */
-        fullpath[i] = c;
-
-    }
-
-    /* Append filename */
-    for (; i<GUAC_COMMON_SSH_SFTP_MAX_PATH; i++) {
-
-        char c = *(filename++);
-        if (c == '\0')
-            break;
-
-        /* Filenames may not contain slashes */
-        if (c == '\\' || c == '/')
-            return 0;
-
-        /* Append each character within filename */
-        fullpath[i] = c;
-
-    }
-
-    /* Verify path length is within maximum */
-    if (i == GUAC_COMMON_SSH_SFTP_MAX_PATH)
+    /* Filenames may not contain slashes */
+    if (strchr(filename, '/') != NULL)
         return 0;
 
-    /* Terminate path string */
-    fullpath[i] = '\0';
+    /* Copy base path */
+    length = guac_strlcpy(fullpath, path, GUAC_COMMON_SSH_SFTP_MAX_PATH);
+
+    /*
+     * Append trailing slash only if:
+     *  1) Trailing slash is not already present
+     *  2) Path is non-empty
+     */
+    if (length > 0 && fullpath[length - 1] != '/')
+        length += guac_strlcpy(fullpath + length, "/",
+                GUAC_COMMON_SSH_SFTP_MAX_PATH - length);
+
+    /* Append filename */
+    length += guac_strlcpy(fullpath + length, filename,
+            GUAC_COMMON_SSH_SFTP_MAX_PATH - length);
+
+    /* Verify path length is within maximum */
+    if (length >= GUAC_COMMON_SSH_SFTP_MAX_PATH)
+        return 0;
 
     /* Append was successful */
     return 1;
@@ -310,46 +254,30 @@
 static int guac_ssh_append_path(char* fullpath, const char* path_a,
         const char* path_b) {
 
-    int i;
+    int length;
 
-    /* Copy path, appending a trailing slash */
-    for (i = 0; i < GUAC_COMMON_SSH_SFTP_MAX_PATH; i++) {
+    /* Copy first half of path */
+    length = guac_strlcpy(fullpath, path_a, GUAC_COMMON_SSH_SFTP_MAX_PATH);
+    if (length >= GUAC_COMMON_SSH_SFTP_MAX_PATH)
+        return 0;
 
-        char c = path_a[i];
-        if (c == '\0') {
-            if (i > 0 && path_a[i-1] != '/')
-                fullpath[i++] = '/';
-            break;
-        }
-
-        /* Copy character if not end of string */
-        fullpath[i] = c;
-
-    }
+    /* Ensure path ends with trailing slash */
+    if (length == 0 || fullpath[length - 1] != '/')
+        length += guac_strlcpy(fullpath + length, "/",
+                GUAC_COMMON_SSH_SFTP_MAX_PATH - length);
 
     /* Skip past leading slashes in second path */
     while (*path_b == '/')
        path_b++;
 
-    /* Append path */
-    for (; i < GUAC_COMMON_SSH_SFTP_MAX_PATH; i++) {
-
-        char c = *(path_b++);
-        if (c == '\0')
-            break;
-
-        /* Append each character within path */
-        fullpath[i] = c;
-
-    }
+    /* Append final half of path */
+    length += guac_strlcpy(fullpath + length, path_b,
+            GUAC_COMMON_SSH_SFTP_MAX_PATH - length);
 
     /* Verify path length is within maximum */
-    if (i == GUAC_COMMON_SSH_SFTP_MAX_PATH)
+    if (length >= GUAC_COMMON_SSH_SFTP_MAX_PATH)
         return 0;
 
-    /* Terminate path string */
-    fullpath[i] = '\0';
-
     /* Append was successful */
     return 1;
 
@@ -830,8 +758,17 @@
 
         list_state->directory = dir;
         list_state->filesystem = filesystem;
-        strncpy(list_state->directory_name, name,
-                sizeof(list_state->directory_name) - 1);
+
+        int length = guac_strlcpy(list_state->directory_name, name,
+                sizeof(list_state->directory_name));
+
+        /* Bail out if directory name is too long to store */
+        if (length >= sizeof(list_state->directory_name)) {
+            guac_user_log(user, GUAC_LOG_INFO, "Unable to read directory "
+                    "\"%s\": Path too long", fullpath);
+            free(list_state);
+            return 0;
+        }
 
         /* Allocate stream for body */
         guac_stream* stream = guac_user_alloc_stream(user);
diff --git a/src/common-ssh/tests/Makefile.am b/src/common-ssh/tests/Makefile.am
new file mode 100644
index 0000000..b26d5bb
--- /dev/null
+++ b/src/common-ssh/tests/Makefile.am
@@ -0,0 +1,66 @@
+#
+# 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 common SSH support
+#
+
+check_PROGRAMS = test_common_ssh
+TESTS = $(check_PROGRAMS)
+
+test_common_ssh_SOURCES = \
+    sftp/normalize_path.c
+
+test_common_ssh_CFLAGS =    \
+    -Werror -Wall -pedantic \
+    @COMMON_INCLUDE@        \
+    @COMMON_SSH_INCLUDE@
+
+test_common_ssh_LDADD = \
+    @CUNIT_LIBS@        \
+    @COMMON_SSH_LTLIB@  \
+    @COMMON_LTLIB@
+
+#
+# Autogenerate test runner
+#
+
+GEN_RUNNER = $(top_srcdir)/util/generate-test-runner.pl
+CLEANFILES = _generated_runner.c
+
+_generated_runner.c: $(test_common_ssh_SOURCES)
+	$(AM_V_GEN) $(GEN_RUNNER) $(test_common_ssh_SOURCES) > $@
+
+nodist_test_common_ssh_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/common-ssh/tests/sftp/normalize_path.c b/src/common-ssh/tests/sftp/normalize_path.c
new file mode 100644
index 0000000..151b183
--- /dev/null
+++ b/src/common-ssh/tests/sftp/normalize_path.c
@@ -0,0 +1,263 @@
+/*
+ * 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 "common-ssh/sftp.h"
+
+#include <CUnit/CUnit.h>
+#include <stdlib.h>
+
+/**
+ * Test which verifies absolute Windows-style paths are correctly normalized to
+ * absolute paths with UNIX separators and no relative components.
+ */
+void test_sftp__normalize_absolute_windows() {
+
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo\\bar\\baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/bar/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo\\bar\\..\\baz\\"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo\\bar\\..\\..\\baz\\a\\..\\b"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/baz/b", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo\\.\\bar\\baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/bar/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo\\bar\\..\\..\\..\\..\\..\\..\\baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/baz", sizeof(normalized));
+
+}
+
+/**
+ * Test which verifies absolute UNIX-style paths are correctly normalized to
+ * absolute paths with UNIX separators and no relative components.
+ */
+void test_sftp__normalize_absolute_unix() {
+
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/foo/bar/baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/bar/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/foo/bar/../baz/"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/foo/bar/../../baz/a/../b"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/baz/b", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/foo/./bar/baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/bar/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/foo/bar/../../../../../../baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/baz", sizeof(normalized));
+
+}
+
+/**
+ * Test which verifies absolute paths consisting of mixed Windows and UNIX path
+ * separators are correctly normalized to absolute paths with UNIX separators
+ * and no relative components.
+ */
+void test_sftp__normalize_absolute_mixed() {
+
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo/bar\\baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/bar/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "/foo\\bar/..\\baz/"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo/bar\\../../baz\\a\\..\\b"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/baz/b", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo\\.\\bar/baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/foo/bar/baz", sizeof(normalized));
+
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "\\foo/bar\\../..\\..\\..\\../..\\baz"), 0);
+    CU_ASSERT_NSTRING_EQUAL(normalized, "/baz", sizeof(normalized));
+
+}
+
+/**
+ * Test which verifies relative Windows-style paths are always rejected.
+ */
+void test_sftp__normalize_relative_windows() {
+
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ""), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "."), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ".."), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "foo"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ".\\foo"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "..\\foo"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "foo\\bar\\baz"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ".\\foo\\bar\\baz"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "..\\foo\\bar\\baz"), 0);
+
+}
+
+/**
+ * Test which verifies relative UNIX-style paths are always rejected.
+ */
+void test_sftp__normalize_relative_unix() {
+
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ""), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "."), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ".."), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "foo"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "./foo"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "../foo"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "foo/bar/baz"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "./foo/bar/baz"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "../foo/bar/baz"), 0);
+
+}
+
+/**
+ * Test which verifies relative paths consisting of mixed Windows and UNIX path
+ * separators are always rejected.
+ */
+void test_sftp__normalize_relative_mixed() {
+
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "foo\\bar/baz"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, ".\\foo/bar/baz"), 0);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, "../foo\\bar\\baz"), 0);
+
+}
+
+/**
+ * Generates a dynamically-allocated path having the given number of bytes, not
+ * counting the null-terminator. The path will contain only UNIX-style path
+ * separators. The returned path must eventually be freed with a call to
+ * free().
+ *
+ * @param length
+ *     The number of bytes to include in the generated path, not counting the
+ *     null-terminator. If -1, the length of the path will be automatically
+ *     determined from the provided max_depth.
+ *
+ * @param max_depth
+ *     The maximum number of path components to include within the generated
+ *     path.
+ *
+ * @return
+ *     A dynamically-allocated path containing the given number of bytes, not
+ *     counting the null-terminator. This path must eventually be freed with a
+ *     call to free().
+ */
+static char* generate_path(int length, int max_depth) {
+
+    /* If no length given, calculate space required from max_depth */
+    if (length == -1)
+        length = max_depth * 2;
+
+    int i;
+    char* input = malloc(length + 1);
+
+    /* Fill path with /x/x/x/x/x/x/x/x/x/x/.../xxxxxxxxx... */
+    for (i = 0; i < length; i++) {
+        if (max_depth > 0 && i % 2 == 0) {
+            input[i] = '/';
+            max_depth--;
+        }
+        else
+            input[i] = 'x';
+    }
+
+    /* Add null terminator */
+    input[length] = '\0';
+
+    return input;
+
+}
+
+/**
+ * Test which verifies that paths exceeding the maximum path length are
+ * rejected.
+ */
+void test_sftp__normalize_long() {
+
+    char* input;
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    /* Exceeds maximum length by a factor of 2 */
+    input = generate_path(GUAC_COMMON_SSH_SFTP_MAX_PATH * 2, GUAC_COMMON_SSH_SFTP_MAX_DEPTH);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+    /* Exceeds maximum length by one byte */
+    input = generate_path(GUAC_COMMON_SSH_SFTP_MAX_PATH, GUAC_COMMON_SSH_SFTP_MAX_DEPTH);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+    /* Exactly maximum length */
+    input = generate_path(GUAC_COMMON_SSH_SFTP_MAX_PATH - 1, GUAC_COMMON_SSH_SFTP_MAX_DEPTH);
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+}
+
+/**
+ * Test which verifies that paths exceeding the maximum path depth are
+ * rejected.
+ */
+void test_sftp__normalize_deep() {
+
+    char* input;
+    char normalized[GUAC_COMMON_SSH_SFTP_MAX_PATH];
+
+    /* Exceeds maximum depth by a factor of 2 */
+    input = generate_path(-1, GUAC_COMMON_SSH_SFTP_MAX_DEPTH * 2);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+    /* Exceeds maximum depth by one component */
+    input = generate_path(-1, GUAC_COMMON_SSH_SFTP_MAX_DEPTH + 1);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+    /* Exactly maximum depth (should still be rejected as SFTP depth limits are
+     * set such that a path with the maximum depth will exceed the maximum
+     * length) */
+    input = generate_path(-1, GUAC_COMMON_SSH_SFTP_MAX_DEPTH);
+    CU_ASSERT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+    /* Less than maximum depth */
+    input = generate_path(-1, GUAC_COMMON_SSH_SFTP_MAX_DEPTH - 1);
+    CU_ASSERT_NOT_EQUAL(guac_common_ssh_sftp_normalize_path(normalized, input), 0);
+    free(input);
+
+}
+
diff --git a/src/common/.gitignore b/src/common/.gitignore
index f7efbda..b66a6a9 100644
--- a/src/common/.gitignore
+++ b/src/common/.gitignore
@@ -3,7 +3,3 @@
 _generated_runner.c
 test_common
 
-# Test suite output
-*.log
-*.trs
-
diff --git a/src/common/clipboard.c b/src/common/clipboard.c
index eb2f548..e02a7ca 100644
--- a/src/common/clipboard.c
+++ b/src/common/clipboard.c
@@ -23,6 +23,7 @@
 #include <guacamole/client.h>
 #include <guacamole/protocol.h>
 #include <guacamole/stream.h>
+#include <guacamole/string.h>
 #include <guacamole/user.h>
 #include <pthread.h>
 #include <string.h>
@@ -131,8 +132,7 @@
     clipboard->length = 0;
 
     /* Assign given mimetype */
-    strncpy(clipboard->mimetype, mimetype, sizeof(clipboard->mimetype) - 1);
-    clipboard->mimetype[sizeof(clipboard->mimetype) - 1] = '\0';
+    guac_strlcpy(clipboard->mimetype, mimetype, sizeof(clipboard->mimetype));
 
     pthread_mutex_unlock(&(clipboard->lock));
 
diff --git a/src/common/tests/Makefile.am b/src/common/tests/Makefile.am
index 8169d7d..a9c1b55 100644
--- a/src/common/tests/Makefile.am
+++ b/src/common/tests/Makefile.am
@@ -60,7 +60,7 @@
 CLEANFILES = _generated_runner.c
 
 _generated_runner.c: $(test_common_SOURCES)
-	$(AM_V_GEN) $(GEN_RUNNER) $^ > $@
+	$(AM_V_GEN) $(GEN_RUNNER) $(test_common_SOURCES) > $@
 
 nodist_test_common_SOURCES = \
     _generated_runner.c
diff --git a/src/guacd/.gitignore b/src/guacd/.gitignore
index 558c319..4f9ae75 100644
--- a/src/guacd/.gitignore
+++ b/src/guacd/.gitignore
@@ -13,38 +13,3 @@
 man/guacd.8
 man/guacd.conf.5
 
-# Object code
-*.o
-*.so
-*.lo
-*.la
-
-# Backup files
-*~
-
-# Release files
-*.tar.gz
-
-# Files currently being edited by vim or vi
-*.swp
-
-# automake/autoconf
-.deps/
-.libs/
-Makefile
-Makefile.in
-aclocal.m4
-autom4te.cache/
-m4/
-config.guess
-config.log
-config.status
-config.sub
-configure
-depcomp
-install-sh
-libtool
-ltmain.sh
-missing
-
-
diff --git a/src/libguac/.gitignore b/src/libguac/.gitignore
index ab58079..9adc856 100644
--- a/src/libguac/.gitignore
+++ b/src/libguac/.gitignore
@@ -3,7 +3,3 @@
 _generated_runner.c
 test_libguac
 
-# Test suite output
-*.log
-*.trs
-
diff --git a/src/libguac/Makefile.am b/src/libguac/Makefile.am
index 16b0e2c..dc96a87 100644
--- a/src/libguac/Makefile.am
+++ b/src/libguac/Makefile.am
@@ -61,6 +61,7 @@
     guacamole/socket-types.h          \
     guacamole/stream.h                \
     guacamole/stream-types.h          \
+    guacamole/string.h                \
     guacamole/timestamp.h             \
     guacamole/timestamp-types.h       \
     guacamole/unicode.h               \
@@ -96,6 +97,7 @@
     socket-fd.c        \
     socket-nest.c      \
     socket-tee.c       \
+    string.c           \
     timestamp.c        \
     unicode.c          \
     user.c             \
@@ -122,7 +124,7 @@
 endif
 
 libguac_la_CFLAGS = \
-    -Werror -Wall -pedantic -I$(srcdir)/guacamole
+    -Werror -Wall -pedantic
 
 libguac_la_LDFLAGS =     \
     -version-info 17:0:0 \
diff --git a/src/libguac/audio.c b/src/libguac/audio.c
index cf0187a..cc577c7 100644
--- a/src/libguac/audio.c
+++ b/src/libguac/audio.c
@@ -19,14 +19,13 @@
 
 #include "config.h"
 
+#include "guacamole/audio.h"
+#include "guacamole/client.h"
+#include "guacamole/protocol.h"
+#include "guacamole/stream.h"
+#include "guacamole/user.h"
 #include "raw_encoder.h"
 
-#include <guacamole/audio.h>
-#include <guacamole/client.h>
-#include <guacamole/protocol.h>
-#include <guacamole/stream.h>
-#include <guacamole/user.h>
-
 #include <stdlib.h>
 #include <string.h>
 
diff --git a/src/libguac/client.c b/src/libguac/client.c
index 4f3051d..80eb4ea 100644
--- a/src/libguac/client.c
+++ b/src/libguac/client.c
@@ -19,20 +19,21 @@
 
 #include "config.h"
 
-#include "client.h"
 #include "encode-jpeg.h"
 #include "encode-png.h"
 #include "encode-webp.h"
-#include "error.h"
+#include "guacamole/client.h"
+#include "guacamole/error.h"
+#include "guacamole/layer.h"
+#include "guacamole/plugin.h"
+#include "guacamole/pool.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/stream.h"
+#include "guacamole/string.h"
+#include "guacamole/timestamp.h"
+#include "guacamole/user.h"
 #include "id.h"
-#include "layer.h"
-#include "pool.h"
-#include "plugin.h"
-#include "protocol.h"
-#include "socket.h"
-#include "stream.h"
-#include "timestamp.h"
-#include "user.h"
 
 #include <dlfcn.h>
 #include <inttypes.h>
@@ -441,8 +442,13 @@
     } alias;
 
     /* Add protocol and .so suffix to protocol_lib */
-    strncat(protocol_lib, protocol, GUAC_PROTOCOL_NAME_LIMIT-1);
-    strcat(protocol_lib, GUAC_PROTOCOL_LIBRARY_SUFFIX);
+    guac_strlcat(protocol_lib, protocol, sizeof(protocol_lib));
+    if (guac_strlcat(protocol_lib, GUAC_PROTOCOL_LIBRARY_SUFFIX,
+                sizeof(protocol_lib)) >= sizeof(protocol_lib)) {
+        guac_error = GUAC_STATUS_NO_MEMORY;
+        guac_error_message = "Protocol name is too long";
+        return -1;
+    }
 
     /* Load client plugin */
     client_plugin_handle = dlopen(protocol_lib, RTLD_LAZY);
diff --git a/src/libguac/encode-jpeg.c b/src/libguac/encode-jpeg.c
index 5a869c7..1aeb23b 100644
--- a/src/libguac/encode-jpeg.c
+++ b/src/libguac/encode-jpeg.c
@@ -20,10 +20,10 @@
 #include "config.h"
 
 #include "encode-jpeg.h"
-#include "error.h"
+#include "guacamole/error.h"
+#include "guacamole/protocol.h"
+#include "guacamole/stream.h"
 #include "palette.h"
-#include "protocol.h"
-#include "stream.h"
 
 #include <cairo/cairo.h>
 #include <jpeglib.h>
diff --git a/src/libguac/encode-jpeg.h b/src/libguac/encode-jpeg.h
index d791ea6..05cf2ca 100644
--- a/src/libguac/encode-jpeg.h
+++ b/src/libguac/encode-jpeg.h
@@ -22,8 +22,8 @@
 
 #include "config.h"
 
-#include "socket.h"
-#include "stream.h"
+#include "guacamole/socket.h"
+#include "guacamole/stream.h"
 
 #include <cairo/cairo.h>
 
diff --git a/src/libguac/encode-png.c b/src/libguac/encode-png.c
index 7879557..7d4b6c7 100644
--- a/src/libguac/encode-png.c
+++ b/src/libguac/encode-png.c
@@ -20,10 +20,10 @@
 #include "config.h"
 
 #include "encode-png.h"
-#include "error.h"
+#include "guacamole/error.h"
+#include "guacamole/protocol.h"
+#include "guacamole/stream.h"
 #include "palette.h"
-#include "protocol.h"
-#include "stream.h"
 
 #include <png.h>
 #include <cairo/cairo.h>
diff --git a/src/libguac/encode-png.h b/src/libguac/encode-png.h
index 916493c..222c50e 100644
--- a/src/libguac/encode-png.h
+++ b/src/libguac/encode-png.h
@@ -22,8 +22,8 @@
 
 #include "config.h"
 
-#include "socket.h"
-#include "stream.h"
+#include "guacamole/socket.h"
+#include "guacamole/stream.h"
 
 #include <cairo/cairo.h>
 
diff --git a/src/libguac/encode-webp.c b/src/libguac/encode-webp.c
index 88534db..5c2237d 100644
--- a/src/libguac/encode-webp.c
+++ b/src/libguac/encode-webp.c
@@ -20,10 +20,10 @@
 #include "config.h"
 
 #include "encode-webp.h"
-#include "error.h"
+#include "guacamole/error.h"
+#include "guacamole/protocol.h"
+#include "guacamole/stream.h"
 #include "palette.h"
-#include "protocol.h"
-#include "stream.h"
 
 #include <cairo/cairo.h>
 #include <webp/encode.h>
diff --git a/src/libguac/encode-webp.h b/src/libguac/encode-webp.h
index 8971765..5347f6d 100644
--- a/src/libguac/encode-webp.h
+++ b/src/libguac/encode-webp.h
@@ -22,8 +22,8 @@
 
 #include "config.h"
 
-#include "socket.h"
-#include "stream.h"
+#include "guacamole/socket.h"
+#include "guacamole/stream.h"
 
 #include <cairo/cairo.h>
 
diff --git a/src/libguac/error.c b/src/libguac/error.c
index 95942e8..5561b17 100644
--- a/src/libguac/error.c
+++ b/src/libguac/error.c
@@ -19,7 +19,7 @@
 
 #include "config.h"
 
-#include "error.h"
+#include "guacamole/error.h"
 
 #include <errno.h>
 #include <stdlib.h>
diff --git a/src/libguac/guacamole/string.h b/src/libguac/guacamole/string.h
new file mode 100644
index 0000000..5e56e33
--- /dev/null
+++ b/src/libguac/guacamole/string.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_STRING_H
+#define GUAC_STRING_H
+
+/**
+ * Provides convenience functions for manipulating strings.
+ *
+ * @file string.h
+ */
+
+#include <stddef.h>
+#include <string.h>
+
+/**
+ * Copies a limited number of bytes from the given source string to the given
+ * destination buffer. The resulting buffer will always be null-terminated,
+ * even if doing so means that the intended string is truncated, unless the
+ * destination buffer has no space available at all. As this function always
+ * returns the length of the string it tried to create (the length of the
+ * source string), whether truncation has occurred can be detected by comparing
+ * the return value against the size of the destination buffer. If the value
+ * returned is greater than or equal to the size of the destination buffer, then
+ * the string has been truncated.
+ *
+ * The source and destination buffers MAY NOT overlap.
+ *
+ * @param dest
+ *     The buffer which should receive the contents of the source string. This
+ *     buffer will always be null terminated unless zero bytes are available
+ *     within the buffer.
+ *
+ * @param src
+ *     The source string to copy into the destination buffer. This string MUST
+ *     be null terminated.
+ *
+ * @param n
+ *     The number of bytes available within the destination buffer. If this
+ *     value is zero, no bytes will be written to the destination buffer, and
+ *     the destination buffer may not be null terminated. In all other cases,
+ *     the destination buffer will always be null terminated, even if doing
+ *     so means that the copied data from the source string will be truncated.
+ *
+ * @return
+ *     The length of the copied string (the source string) in bytes, excluding
+ *     the null terminator.
+ */
+size_t guac_strlcpy(char* restrict dest, const char* restrict src, size_t n);
+
+/**
+ * Appends the given source string after the end of the given destination
+ * string, writing at most the given number of bytes. Both the source and
+ * destination strings MUST be null-terminated. The resulting buffer will
+ * always be null-terminated, even if doing so means that the intended string
+ * is truncated, unless the destination buffer has no space available at all.
+ * As this function always returns the length of the string it tried to create
+ * (the length of destination and source strings added together), whether
+ * truncation has occurred can be detected by comparing the return value
+ * against the size of the destination buffer. If the value returned is greater
+ * than or equal to the size of the destination buffer, then the string has
+ * been truncated.
+ *
+ * The source and destination buffers MAY NOT overlap.
+ *
+ * @param dest
+ *     The buffer which should be appended with the contents of the source
+ *     string. This buffer MUST already be null-terminated and will always be
+ *     null-terminated unless zero bytes are available within the buffer.
+ *
+ *     As a safeguard against incorrectly-written code, in the event that the
+ *     destination buffer is not null-terminated, this function will still stop
+ *     before overrunning the buffer, instead behaving as if the length of the
+ *     string in the buffer is exactly the size of the buffer. The destination
+ *     buffer will remain untouched (and unterminated) in this case.
+ *
+ * @param src
+ *     The source string to append to the the destination buffer. This string
+ *     MUST be null-terminated.
+ *
+ * @param n
+ *     The number of bytes available within the destination buffer. If this
+ *     value is not greater than zero, no bytes will be written to the
+ *     destination buffer, and the destination buffer may not be
+ *     null-terminated. In all other cases, the destination buffer will always
+ *     be null-terminated, even if doing so means that the copied data from the
+ *     source string will be truncated.
+ *
+ * @return
+ *     The length of the string this function tried to create (the lengths of
+ *     the source and destination strings added together) in bytes, excluding
+ *     the null terminator.
+ */
+size_t guac_strlcat(char* restrict dest, const char* restrict src, size_t n);
+
+/**
+ * Concatenates each of the given strings, separated by the given delimiter,
+ * storing the result within a destination buffer. The number of bytes written
+ * will be no more than the given number of bytes, and the destination buffer
+ * is guaranteed to be null-terminated, even if doing so means that one or more
+ * of the intended strings are truncated or omitted from the end of the result,
+ * unless the destination buffer has no space available at all. As this
+ * function always returns the length of the string it tried to create (the
+ * length of all source strings and all delimiters added together), whether
+ * truncation has occurred can be detected by comparing the return value
+ * against the size of the destination buffer. If the value returned is greater
+ * than or equal to the size of the destination buffer, then the string has
+ * been truncated.
+ *
+ * The source strings, delimiter string, and destination buffer MAY NOT
+ * overlap.
+ *
+ * @param dest
+ *     The buffer which should receive the result of joining the given strings.
+ *     This buffer will always be null terminated unless zero bytes are
+ *     available within the buffer.
+ *
+ * @param elements
+ *     The elements to concatenate together, separated by the given delimiter.
+ *     Each element MUST be null-terminated.
+ *
+ * @param nmemb
+ *     The number of elements within the elements array.
+ *
+ * @param delim
+ *     The delimiter to include between each pair of elements.
+ *
+ * @param n
+ *     The number of bytes available within the destination buffer. If this
+ *     value is not greater than zero, no bytes will be written to the
+ *     destination buffer, and the destination buffer may not be null
+ *     terminated. In all other cases, the destination buffer will always be
+ *     null terminated, even if doing so means that the result will be
+ *     truncated.
+ *
+ * @return
+ *     The length of the string this function tried to create (the length of
+ *     all source strings and all delimiters added together) in bytes,
+ *     excluding the null terminator.
+ */
+size_t guac_strljoin(char* restrict dest, const char* restrict const* elements,
+        int nmemb, const char* restrict delim, size_t n);
+
+#endif
+
diff --git a/src/libguac/id.c b/src/libguac/id.c
index 56ef23c..27a714c 100644
--- a/src/libguac/id.c
+++ b/src/libguac/id.c
@@ -19,8 +19,8 @@
 
 #include "config.h"
 
+#include "guacamole/error.h"
 #include "id.h"
-#include "error.h"
 
 #ifdef HAVE_OSSP_UUID_H
 #include <ossp/uuid.h>
diff --git a/src/libguac/parser.c b/src/libguac/parser.c
index 799b48a..9645a2a 100644
--- a/src/libguac/parser.c
+++ b/src/libguac/parser.c
@@ -19,10 +19,10 @@
 
 #include "config.h"
 
-#include "error.h"
-#include "parser.h"
-#include "socket.h"
-#include "unicode.h"
+#include "guacamole/error.h"
+#include "guacamole/parser.h"
+#include "guacamole/socket.h"
+#include "guacamole/unicode.h"
 
 #include <stdlib.h>
 #include <stdio.h>
diff --git a/src/libguac/pool.c b/src/libguac/pool.c
index 363c239..9db43ad 100644
--- a/src/libguac/pool.c
+++ b/src/libguac/pool.c
@@ -19,7 +19,7 @@
 
 #include "config.h"
 
-#include "pool.h"
+#include "guacamole/pool.h"
 
 #include <stdlib.h>
 
diff --git a/src/libguac/protocol.c b/src/libguac/protocol.c
index dba9c56..ee266e8 100644
--- a/src/libguac/protocol.c
+++ b/src/libguac/protocol.c
@@ -19,14 +19,14 @@
 
 #include "config.h"
 
-#include "error.h"
-#include "layer.h"
-#include "object.h"
+#include "guacamole/error.h"
+#include "guacamole/layer.h"
+#include "guacamole/object.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/stream.h"
+#include "guacamole/unicode.h"
 #include "palette.h"
-#include "protocol.h"
-#include "socket.h"
-#include "stream.h"
-#include "unicode.h"
 
 #include <cairo/cairo.h>
 
diff --git a/src/libguac/raw_encoder.c b/src/libguac/raw_encoder.c
index 1dd03cc..9086a37 100644
--- a/src/libguac/raw_encoder.c
+++ b/src/libguac/raw_encoder.c
@@ -19,15 +19,13 @@
 
 #include "config.h"
 
-#include "audio.h"
+#include "guacamole/audio.h"
+#include "guacamole/client.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/user.h"
 #include "raw_encoder.h"
 
-#include <guacamole/audio.h>
-#include <guacamole/client.h>
-#include <guacamole/protocol.h>
-#include <guacamole/socket.h>
-#include <guacamole/user.h>
-
 #include <stdlib.h>
 #include <stdio.h>
 #include <string.h>
diff --git a/src/libguac/raw_encoder.h b/src/libguac/raw_encoder.h
index b405040..c3af151 100644
--- a/src/libguac/raw_encoder.h
+++ b/src/libguac/raw_encoder.h
@@ -23,7 +23,7 @@
 
 #include "config.h"
 
-#include "audio.h"
+#include "guacamole/audio.h"
 
 /**
  * The number of bytes to send in each audio blob.
diff --git a/src/libguac/socket-broadcast.c b/src/libguac/socket-broadcast.c
index a3f17fd..f551e81 100644
--- a/src/libguac/socket-broadcast.c
+++ b/src/libguac/socket-broadcast.c
@@ -19,10 +19,10 @@
 
 #include "config.h"
 
-#include "client.h"
-#include "error.h"
-#include "socket.h"
-#include "user.h"
+#include "guacamole/client.h"
+#include "guacamole/error.h"
+#include "guacamole/socket.h"
+#include "guacamole/user.h"
 
 #include <pthread.h>
 #include <stdlib.h>
diff --git a/src/libguac/socket-fd.c b/src/libguac/socket-fd.c
index 3489852..742cc35 100644
--- a/src/libguac/socket-fd.c
+++ b/src/libguac/socket-fd.c
@@ -19,8 +19,8 @@
 
 #include "config.h"
 
-#include "error.h"
-#include "socket.h"
+#include "guacamole/error.h"
+#include "guacamole/socket.h"
 #include "wait-fd.h"
 
 #include <pthread.h>
diff --git a/src/libguac/socket-nest.c b/src/libguac/socket-nest.c
index 967c9d9..8bc9291 100644
--- a/src/libguac/socket-nest.c
+++ b/src/libguac/socket-nest.c
@@ -19,9 +19,9 @@
 
 #include "config.h"
 
-#include "protocol.h"
-#include "socket.h"
-#include "unicode.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/unicode.h"
 
 #include <stddef.h>
 #include <stdlib.h>
diff --git a/src/libguac/socket-ssl.c b/src/libguac/socket-ssl.c
index 1a631fe..3daa128 100644
--- a/src/libguac/socket-ssl.c
+++ b/src/libguac/socket-ssl.c
@@ -19,9 +19,9 @@
 
 #include "config.h"
 
-#include "error.h"
-#include "socket-ssl.h"
-#include "socket.h"
+#include "guacamole/error.h"
+#include "guacamole/socket-ssl.h"
+#include "guacamole/socket.h"
 #include "wait-fd.h"
 
 #include <stdlib.h>
diff --git a/src/libguac/socket-tee.c b/src/libguac/socket-tee.c
index 3f0fd6b..cee9108 100644
--- a/src/libguac/socket-tee.c
+++ b/src/libguac/socket-tee.c
@@ -19,7 +19,7 @@
 
 #include "config.h"
 
-#include "socket.h"
+#include "guacamole/socket.h"
 
 #include <stdlib.h>
 
diff --git a/src/libguac/socket-wsa.c b/src/libguac/socket-wsa.c
index 1b59176..f5602e3 100644
--- a/src/libguac/socket-wsa.c
+++ b/src/libguac/socket-wsa.c
@@ -17,8 +17,8 @@
  * under the License.
  */
 
-#include "error.h"
-#include "socket.h"
+#include "guacamole/error.h"
+#include "guacamole/socket.h"
 
 #include <pthread.h>
 #include <stddef.h>
diff --git a/src/libguac/socket.c b/src/libguac/socket.c
index 6bc0036..66442d0 100644
--- a/src/libguac/socket.c
+++ b/src/libguac/socket.c
@@ -19,10 +19,10 @@
 
 #include "config.h"
 
-#include "error.h"
-#include "protocol.h"
-#include "socket.h"
-#include "timestamp.h"
+#include "guacamole/error.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/timestamp.h"
 
 #include <inttypes.h>
 #include <pthread.h>
diff --git a/src/libguac/string.c b/src/libguac/string.c
new file mode 100644
index 0000000..f05c4c0
--- /dev/null
+++ b/src/libguac/string.c
@@ -0,0 +1,107 @@
+/*
+ * 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 <stddef.h>
+#include <string.h>
+
+/**
+ * Returns the space remaining in a buffer assuming that the given number of
+ * bytes have already been written. If the number of bytes exceeds the size
+ * of the buffer, zero is returned.
+ *
+ * @param n
+ *     The size of the buffer in bytes.
+ *
+ * @param length
+ *     The number of bytes which have been written to the buffer so far. If
+ *     the routine writing the bytes will automatically truncate its writes,
+ *     this value may exceed the size of the buffer.
+ *
+ * @return
+ *     The number of bytes remaining in the buffer. This value will always
+ *     be non-negative. If the number of bytes written already exceeds the
+ *     size of the buffer, zero will be returned.
+ */
+#define REMAINING(n, length) (((n) < (length)) ? 0 : ((n) - (length)))
+
+size_t guac_strlcpy(char* restrict dest, const char* restrict src, size_t n) {
+
+#ifdef HAVE_STRLCPY
+    return strlcpy(dest, src, n);
+#else
+    /* Calculate actual length of desired string */
+    size_t length = strlen(src);
+
+    /* Copy nothing if there is no space */
+    if (n == 0)
+        return length;
+
+    /* Calculate length of the string which will be copied */
+    size_t copy_length = length;
+    if (copy_length >= n)
+        copy_length = n - 1;
+
+    /* Copy only as much of string as possible, manually adding a null
+     * terminator */
+    memcpy(dest, src, copy_length);
+    dest[copy_length] = '\0';
+
+    /* Return the overall length of the desired string */
+    return length;
+#endif
+
+}
+
+size_t guac_strlcat(char* restrict dest, const char* restrict src, size_t n) {
+
+#ifdef HAVE_STRLCPY
+    return strlcat(dest, src, n);
+#else
+    size_t length = strnlen(dest, n);
+    return length + guac_strlcpy(dest + length, src, REMAINING(n, length));
+#endif
+
+}
+
+size_t guac_strljoin(char* restrict dest, const char* restrict const* elements,
+        int nmemb, const char* restrict delim, size_t n) {
+
+    size_t length = 0;
+    const char* restrict const* current = elements;
+
+    /* If no elements are provided, nothing to do but ensure the destination
+     * buffer is null terminated */
+    if (nmemb <= 0)
+        return guac_strlcpy(dest, "", n);
+
+    /* Initialize destination buffer with first element */
+    length += guac_strlcpy(dest, *current, n);
+
+    /* Copy all remaining elements, separated by delimiter */
+    for (current++; nmemb > 1; current++, nmemb--) {
+        length += guac_strlcat(dest + length, delim, REMAINING(n, length));
+        length += guac_strlcat(dest + length, *current, REMAINING(n, length));
+    }
+
+    return length;
+
+}
+
diff --git a/src/libguac/tests/Makefile.am b/src/libguac/tests/Makefile.am
index 414a2f4..4ab3269 100644
--- a/src/libguac/tests/Makefile.am
+++ b/src/libguac/tests/Makefile.am
@@ -42,6 +42,9 @@
     protocol/base64_decode.c         \
     socket/fd_send_instruction.c     \
     socket/nested_send_instruction.c \
+    string/strlcat.c                 \
+    string/strlcpy.c                 \
+    string/strljoin.c                \
     unicode/charsize.c               \
     unicode/read.c                   \
     unicode/strlen.c                 \
@@ -64,7 +67,7 @@
 CLEANFILES = _generated_runner.c
 
 _generated_runner.c: $(test_libguac_SOURCES)
-	$(AM_V_GEN) $(GEN_RUNNER) $^ > $@
+	$(AM_V_GEN) $(GEN_RUNNER) $(test_libguac_SOURCES) > $@
 
 nodist_test_libguac_SOURCES = \
     _generated_runner.c
diff --git a/src/libguac/tests/string/strlcat.c b/src/libguac/tests/string/strlcat.c
new file mode 100644
index 0000000..cf3dc33
--- /dev/null
+++ b/src/libguac/tests/string/strlcat.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 <CUnit/CUnit.h>
+#include <guacamole/string.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+/**
+ * Verify guac_strlcat() behavior when the string fits the buffer without
+ * truncation. The return value of each call should be the length of the
+ * resulting string. Each resulting string should contain the full result of
+ * the concatenation, including null terminator.
+ */
+void test_string__strlcat() {
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "Apache ");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "Guacamole", sizeof(buffer)), 16);
+    CU_ASSERT_STRING_EQUAL(buffer, "Apache Guacamole");
+    CU_ASSERT_EQUAL(buffer[17], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "This is a test", sizeof(buffer)), 14);
+    CU_ASSERT_STRING_EQUAL(buffer, "This is a test");
+    CU_ASSERT_EQUAL(buffer[15], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "AB");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "X", sizeof(buffer)), 3);
+    CU_ASSERT_STRING_EQUAL(buffer, "ABX");
+    CU_ASSERT_EQUAL(buffer[4], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "X");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "", sizeof(buffer)), 1);
+    CU_ASSERT_STRING_EQUAL(buffer, "X");
+    CU_ASSERT_EQUAL(buffer[2], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "", sizeof(buffer)), 0);
+    CU_ASSERT_STRING_EQUAL(buffer, "");
+    CU_ASSERT_EQUAL(buffer[1], '\xFF');
+
+}
+
+/**
+ * Verify guac_strlcat() behavior when the string must be truncated to fit the
+ * buffer. The return value of each call should be the length that would result
+ * from concatenating the strings given an infinite buffer, however only as
+ * many characters as can fit should be appended to the string within the
+ * buffer, and the buffer should be null-terminated.
+ */
+void test_string__strlcat_truncate() {
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "Apache ");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "Guacamole", 9), 16);
+    CU_ASSERT_STRING_EQUAL(buffer, "Apache G");
+    CU_ASSERT_EQUAL(buffer[9], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "This is a test", 10), 14);
+    CU_ASSERT_STRING_EQUAL(buffer, "This is a");
+    CU_ASSERT_EQUAL(buffer[10], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    strcpy(buffer, "This ");
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "is ANOTHER test", 6), 20);
+    CU_ASSERT_STRING_EQUAL(buffer, "This ");
+    CU_ASSERT_EQUAL(buffer[6], '\xFF');
+
+}
+
+/**
+ * Verify guac_strlcat() behavior with zero buffer sizes. The return value of
+ * each call should be the size of the input string, while the buffer remains
+ * untouched.
+ */
+void test_string__strlcat_nospace() {
+
+    /* 0-byte buffer plus 1 guard byte (to test overrun) */
+    char buffer[1] = { '\xFF' };
+
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "Guacamole", 0), 9);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "This is a test", 0), 14);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "X", 0), 1);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "", 0), 0);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+}
+
+/**
+ * Verify guac_strlcat() behavior with unterminated buffers. With respect to
+ * the return value, the length of the string in the buffer should be
+ * considered equal to the size of the buffer, however the resulting buffer
+ * should not be null-terminated.
+ */
+void test_string__strlcat_nonull() {
+
+    char expected[1024];
+    memset(expected, 0xFF, sizeof(expected));
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "Guacamole", 256), 265);
+    CU_ASSERT_NSTRING_EQUAL(buffer, expected, sizeof(expected));
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "This is a test", 37), 51);
+    CU_ASSERT_NSTRING_EQUAL(buffer, expected, sizeof(expected));
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "X", 12), 13);
+    CU_ASSERT_NSTRING_EQUAL(buffer, expected, sizeof(expected));
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcat(buffer, "", 100), 100);
+    CU_ASSERT_NSTRING_EQUAL(buffer, expected, sizeof(expected));
+
+}
+
diff --git a/src/libguac/tests/string/strlcpy.c b/src/libguac/tests/string/strlcpy.c
new file mode 100644
index 0000000..1e8e01e
--- /dev/null
+++ b/src/libguac/tests/string/strlcpy.c
@@ -0,0 +1,102 @@
+/*
+ * 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 <CUnit/CUnit.h>
+#include <guacamole/string.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+/**
+ * Verify guac_strlcpy() behavior when the string fits the buffer without
+ * truncation.
+ */
+void test_string__strlcpy() {
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "Guacamole", sizeof(buffer)), 9);
+    CU_ASSERT_STRING_EQUAL(buffer, "Guacamole");
+    CU_ASSERT_EQUAL(buffer[10], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "This is a test", sizeof(buffer)), 14);
+    CU_ASSERT_STRING_EQUAL(buffer, "This is a test");
+    CU_ASSERT_EQUAL(buffer[15], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "X", sizeof(buffer)), 1);
+    CU_ASSERT_STRING_EQUAL(buffer, "X");
+    CU_ASSERT_EQUAL(buffer[2], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "", sizeof(buffer)), 0);
+    CU_ASSERT_STRING_EQUAL(buffer, "");
+    CU_ASSERT_EQUAL(buffer[1], '\xFF');
+
+}
+
+/**
+ * Verify guac_strlcpy() behavior when the string must be truncated to fit the
+ * buffer.
+ */
+void test_string__strlcpy_truncate() {
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "Guacamole", 6), 9);
+    CU_ASSERT_STRING_EQUAL(buffer, "Guaca");
+    CU_ASSERT_EQUAL(buffer[6], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "This is a test", 10), 14);
+    CU_ASSERT_STRING_EQUAL(buffer, "This is a");
+    CU_ASSERT_EQUAL(buffer[10], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "This is ANOTHER test", 2), 20);
+    CU_ASSERT_STRING_EQUAL(buffer, "T");
+    CU_ASSERT_EQUAL(buffer[2], '\xFF');
+
+}
+
+/**
+ * Verify guac_strlcpy() behavior with zero buffer sizes.
+ */
+void test_string__strlcpy_nospace() {
+
+    /* 0-byte buffer plus 1 guard byte (to test overrun) */
+    char buffer[1] = { '\xFF' };
+
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "Guacamole", 0), 9);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "This is a test", 0), 14);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "X", 0), 1);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strlcpy(buffer, "", 0), 0);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+}
+
diff --git a/src/libguac/tests/string/strljoin.c b/src/libguac/tests/string/strljoin.c
new file mode 100644
index 0000000..3993288
--- /dev/null
+++ b/src/libguac/tests/string/strljoin.c
@@ -0,0 +1,148 @@
+/*
+ * 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 <CUnit/CUnit.h>
+#include <guacamole/string.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+/**
+ * Array of test elements containing the strings "Apache" and "Guacamole".
+ */
+const char* const apache_guacamole[] = { "Apache", "Guacamole" };
+
+/**
+ * Array of test elements containing the strings "This", "is", "a", and "test".
+ */
+const char* const this_is_a_test[] = { "This", "is", "a", "test" };
+
+/**
+ * Array of four test elements containing the strings "A" and "B", each
+ * preceded by an empty string ("").
+ */
+const char* const empty_a_empty_b[] = { "", "A", "", "B" };
+
+/**
+ * Array of test elements containing ten empty strings.
+ */
+const char* const empty_x10[] = { "", "", "", "", "", "", "", "", "", "" };
+
+/**
+ * Verify guac_strljoin() behavior when the string fits the buffer without
+ * truncation. The return value of each call should be the length of the
+ * resulting string. Each resulting string should contain the full result of
+ * the join operation, including null terminator.
+ */
+void test_string__strljoin() {
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, apache_guacamole, 2, " ", sizeof(buffer)), 16);
+    CU_ASSERT_STRING_EQUAL(buffer, "Apache Guacamole");
+    CU_ASSERT_EQUAL(buffer[17], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, this_is_a_test, 4, "", sizeof(buffer)), 11);
+    CU_ASSERT_STRING_EQUAL(buffer, "Thisisatest");
+    CU_ASSERT_EQUAL(buffer[12], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, this_is_a_test, 4, "-/-", sizeof(buffer)), 20);
+    CU_ASSERT_STRING_EQUAL(buffer, "This-/-is-/-a-/-test");
+    CU_ASSERT_EQUAL(buffer[21], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, empty_a_empty_b, 4, "/", sizeof(buffer)), 5);
+    CU_ASSERT_STRING_EQUAL(buffer, "/A//B");
+    CU_ASSERT_EQUAL(buffer[6], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, empty_x10, 10, "/", sizeof(buffer)), 9);
+    CU_ASSERT_STRING_EQUAL(buffer, "/////////");
+    CU_ASSERT_EQUAL(buffer[10], '\xFF');
+
+}
+
+/**
+ * Verify guac_strljoin() behavior when the string must be truncated to fit the
+ * buffer. The return value of each call should be the length that would result
+ * from joining the strings given an infinite buffer, however only as many
+ * characters as can fit should be appended to the string within the buffer,
+ * and the buffer should be null-terminated.
+ */
+void test_string__strljoin_truncate() {
+
+    char buffer[1024];
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, apache_guacamole, 2, " ", 9), 16);
+    CU_ASSERT_STRING_EQUAL(buffer, "Apache G");
+    CU_ASSERT_EQUAL(buffer[9], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, this_is_a_test, 4, "", 8), 11);
+    CU_ASSERT_STRING_EQUAL(buffer, "Thisisa");
+    CU_ASSERT_EQUAL(buffer[8], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, this_is_a_test, 4, "-/-", 12), 20);
+    CU_ASSERT_STRING_EQUAL(buffer, "This-/-is-/");
+    CU_ASSERT_EQUAL(buffer[12], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, empty_a_empty_b, 4, "/", 2), 5);
+    CU_ASSERT_STRING_EQUAL(buffer, "/");
+    CU_ASSERT_EQUAL(buffer[2], '\xFF');
+
+    memset(buffer, 0xFF, sizeof(buffer));
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, empty_x10, 10, "/", 7), 9);
+    CU_ASSERT_STRING_EQUAL(buffer, "//////");
+    CU_ASSERT_EQUAL(buffer[7], '\xFF');
+
+}
+
+/**
+ * Verify guac_strljoin() behavior with zero buffer sizes. The return value of
+ * each call should be the size of the input string, while the buffer remains
+ * untouched.
+ */
+void test_string__strljoin_nospace() {
+
+    /* 0-byte buffer plus 1 guard byte (to test overrun) */
+    char buffer[1] = { '\xFF' };
+
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, apache_guacamole, 2, " ", 0), 16);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, this_is_a_test, 4, "", 0), 11);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, this_is_a_test, 4, "-/-", 0), 20);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, empty_a_empty_b, 4, "/", 0), 5);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+    CU_ASSERT_EQUAL(guac_strljoin(buffer, empty_x10, 10, "/", 0), 9);
+    CU_ASSERT_EQUAL(buffer[0], '\xFF');
+
+}
+
diff --git a/src/libguac/timestamp.c b/src/libguac/timestamp.c
index 0d2dc0a..9020a6e 100644
--- a/src/libguac/timestamp.c
+++ b/src/libguac/timestamp.c
@@ -19,7 +19,7 @@
 
 #include "config.h"
 
-#include "timestamp.h"
+#include "guacamole/timestamp.h"
 
 #include <sys/time.h>
 
diff --git a/src/libguac/unicode.c b/src/libguac/unicode.c
index e7af943..fdc0fff 100644
--- a/src/libguac/unicode.c
+++ b/src/libguac/unicode.c
@@ -19,7 +19,7 @@
 
 #include "config.h"
 
-#include "unicode.h"
+#include "guacamole/unicode.h"
 
 #include <stddef.h>
 
diff --git a/src/libguac/user-handlers.c b/src/libguac/user-handlers.c
index b84dc72..6be20e2 100644
--- a/src/libguac/user-handlers.c
+++ b/src/libguac/user-handlers.c
@@ -19,12 +19,12 @@
 
 #include "config.h"
 
-#include "client.h"
-#include "object.h"
-#include "protocol.h"
-#include "stream.h"
-#include "timestamp.h"
-#include "user.h"
+#include "guacamole/client.h"
+#include "guacamole/object.h"
+#include "guacamole/protocol.h"
+#include "guacamole/stream.h"
+#include "guacamole/timestamp.h"
+#include "guacamole/user.h"
 #include "user-handlers.h"
 
 #include <inttypes.h>
diff --git a/src/libguac/user-handlers.h b/src/libguac/user-handlers.h
index eedeba1..5d7c6ea 100644
--- a/src/libguac/user-handlers.h
+++ b/src/libguac/user-handlers.h
@@ -31,8 +31,8 @@
 
 #include "config.h"
 
-#include "client.h"
-#include "timestamp.h"
+#include "guacamole/client.h"
+#include "guacamole/timestamp.h"
 
 /**
  * Internal handler for Guacamole instructions. Instruction handlers will be
diff --git a/src/libguac/user-handshake.c b/src/libguac/user-handshake.c
index b601888..13bea51 100644
--- a/src/libguac/user-handshake.c
+++ b/src/libguac/user-handshake.c
@@ -19,12 +19,12 @@
 
 #include "config.h"
 
-#include "client.h"
-#include "error.h"
-#include "parser.h"
-#include "protocol.h"
-#include "socket.h"
-#include "user.h"
+#include "guacamole/client.h"
+#include "guacamole/error.h"
+#include "guacamole/parser.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/user.h"
 
 #include <pthread.h>
 #include <stdlib.h>
diff --git a/src/libguac/user.c b/src/libguac/user.c
index 14ec75b..1659006 100644
--- a/src/libguac/user.c
+++ b/src/libguac/user.c
@@ -19,18 +19,18 @@
 
 #include "config.h"
 
-#include "client.h"
 #include "encode-jpeg.h"
 #include "encode-png.h"
 #include "encode-webp.h"
+#include "guacamole/client.h"
+#include "guacamole/object.h"
+#include "guacamole/pool.h"
+#include "guacamole/protocol.h"
+#include "guacamole/socket.h"
+#include "guacamole/stream.h"
+#include "guacamole/timestamp.h"
+#include "guacamole/user.h"
 #include "id.h"
-#include "object.h"
-#include "pool.h"
-#include "protocol.h"
-#include "socket.h"
-#include "stream.h"
-#include "timestamp.h"
-#include "user.h"
 #include "user-handlers.h"
 
 #include <errno.h>
diff --git a/src/protocols/rdp/.gitignore b/src/protocols/rdp/.gitignore
index dd8ff14..9f87ecb 100644
--- a/src/protocols/rdp/.gitignore
+++ b/src/protocols/rdp/.gitignore
@@ -1,38 +1,7 @@
 
-# Object code
-*.o
-*.so
-*.lo
-*.la
-
-# Backup files
-*~
-
-# Release files
-*.tar.gz
-
-# Files currently being edited by vim or vi
-*.swp
-
-# automake/autoconf
-.deps/
-.libs/
-Makefile
-Makefile.in
-aclocal.m4
-autom4te.cache/
-m4/*
-!README
-config.guess
-config.log
-config.status
-config.sub
-configure
-depcomp
-install-sh
-libtool
-ltmain.sh
-missing
+# Auto-generated test runner and binary
+_generated_runner.c
+test_rdp
 
 # Autogenerated sources
 _generated_keymaps.c
diff --git a/src/protocols/rdp/Makefile.am b/src/protocols/rdp/Makefile.am
index 29a9674..1ba406c 100644
--- a/src/protocols/rdp/Makefile.am
+++ b/src/protocols/rdp/Makefile.am
@@ -27,6 +27,7 @@
 ACLOCAL_AMFLAGS = -I m4
 
 lib_LTLIBRARIES = libguac-client-rdp.la
+SUBDIRS = . tests
 
 nodist_libguac_client_rdp_la_SOURCES = \
     _generated_keymaps.c
diff --git a/src/protocols/rdp/guac_svc/svc_service.c b/src/protocols/rdp/guac_svc/svc_service.c
index 4a38cb3..c40c6c5 100644
--- a/src/protocols/rdp/guac_svc/svc_service.c
+++ b/src/protocols/rdp/guac_svc/svc_service.c
@@ -29,6 +29,7 @@
 #include <guacamole/client.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
+#include <guacamole/string.h>
 
 #ifdef ENABLE_WINPR
 #include <winpr/stream.h>
@@ -53,7 +54,7 @@
     guac_rdp_svc* svc = (guac_rdp_svc*) entry_points_ex->pExtendedData;
 
     /* Init channel def */
-    strncpy(svc_plugin->plugin.channel_def.name, svc->name,
+    guac_strlcpy(svc_plugin->plugin.channel_def.name, svc->name,
             GUAC_RDP_SVC_MAX_LENGTH);
     svc_plugin->plugin.channel_def.options = 
           CHANNEL_OPTION_INITIALIZED
diff --git a/src/protocols/rdp/keymaps/generate.pl b/src/protocols/rdp/keymaps/generate.pl
index d3764d6..263b616 100755
--- a/src/protocols/rdp/keymaps/generate.pl
+++ b/src/protocols/rdp/keymaps/generate.pl
@@ -1,4 +1,4 @@
-#!/usr/bin/perl
+#!/usr/bin/env perl
 #
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file
diff --git a/src/protocols/rdp/rdp_fs.c b/src/protocols/rdp/rdp_fs.c
index 911d663..24d7c3e 100644
--- a/src/protocols/rdp/rdp_fs.c
+++ b/src/protocols/rdp/rdp_fs.c
@@ -39,6 +39,7 @@
 #include <guacamole/object.h>
 #include <guacamole/pool.h>
 #include <guacamole/socket.h>
+#include <guacamole/string.h>
 #include <guacamole/user.h>
 
 guac_rdp_fs* guac_rdp_fs_alloc(guac_client* client, const char* drive_path,
@@ -606,51 +607,55 @@
 
 int guac_rdp_fs_normalize_path(const char* path, char* abs_path) {
 
-    int i;
     int path_depth = 0;
-    char path_component_data[GUAC_RDP_FS_MAX_PATH];
-    const char* path_components[64];
-
-    const char** current_path_component      = &(path_components[0]);
-    const char*  current_path_component_data = &(path_component_data[0]);
+    const char* path_components[GUAC_RDP_MAX_PATH_DEPTH];
 
     /* If original path is not absolute, normalization fails */
     if (path[0] != '\\' && path[0] != '/')
         return 1;
 
-    /* Skip past leading slash */
-    path++;
+    /* Create scratch copy of path excluding leading slash (we will be
+     * replacing path separators with null terminators and referencing those
+     * substrings directly as path components) */
+    char path_scratch[GUAC_RDP_FS_MAX_PATH - 1];
+    int length = guac_strlcpy(path_scratch, path + 1,
+            sizeof(path_scratch));
 
-    /* Copy path into component data for parsing */
-    strncpy(path_component_data, path, sizeof(path_component_data) - 1);
+    /* Fail if provided path is too long */
+    if (length >= sizeof(path_scratch))
+        return 1;
 
-    /* Find path components within path */
-    for (i = 0; i < sizeof(path_component_data) - 1; i++) {
+    /* Locate all path components within path */
+    const char* current_path_component = &(path_scratch[0]);
+    for (int i = 0; i <= length; i++) {
 
         /* If current character is a path separator, parse as component */
-        char c = path_component_data[i];
-        if (c == '/' || c == '\\' || c == 0) {
+        char c = path_scratch[i];
+        if (c == '/' || c == '\\' || c == '\0') {
 
             /* Terminate current component */
-            path_component_data[i] = 0;
+            path_scratch[i] = '\0';
 
             /* If component refers to parent, just move up in depth */
-            if (strcmp(current_path_component_data, "..") == 0) {
+            if (strcmp(current_path_component, "..") == 0) {
                 if (path_depth > 0)
                     path_depth--;
             }
 
             /* Otherwise, if component not current directory, add to list */
-            else if (strcmp(current_path_component_data,   ".") != 0
-                     && strcmp(current_path_component_data, "") != 0)
-                path_components[path_depth++] = current_path_component_data;
+            else if (strcmp(current_path_component, ".") != 0
+                    && strcmp(current_path_component, "") != 0) {
 
-            /* If end of string, stop */
-            if (c == 0)
-                break;
+                /* Fail normalization if path is too deep */
+                if (path_depth >= GUAC_RDP_MAX_PATH_DEPTH)
+                    return 1;
+
+                path_components[path_depth++] = current_path_component;
+
+            }
 
             /* Update start of next component */
-            current_path_component_data = &(path_component_data[i+1]);
+            current_path_component = &(path_scratch[i+1]);
 
         } /* end if separator */
 
@@ -660,57 +665,32 @@
 
     } /* end for each character */
 
-    /* If no components, the path is simply root */
-    if (path_depth == 0) {
-        strcpy(abs_path, "\\");
-        return 0;
-    }
+    /* Add leading slash for resulting absolute path */
+    abs_path[0] = '\\';
 
-    /* Ensure last component is null-terminated */
-    path_component_data[i] = 0;
+    /* Append normalized components to path, separated by slashes */
+    guac_strljoin(abs_path + 1, path_components, path_depth,
+            "\\", GUAC_RDP_FS_MAX_PATH - 1);
 
-    /* Convert components back into path */
-    for (; path_depth > 0; path_depth--) {
-
-        const char* filename = *(current_path_component++);
-
-        /* Add separator */
-        *(abs_path++) = '\\';
-
-        /* Copy string */
-        while (*filename != 0)
-            *(abs_path++) = *(filename++);
-
-    }
-
-    /* Terminate absolute path */
-    *(abs_path++) = 0;
     return 0;
 
 }
 
 int guac_rdp_fs_convert_path(const char* parent, const char* rel_path, char* abs_path) {
 
-    int i;
+    int length;
     char combined_path[GUAC_RDP_FS_MAX_PATH];
-    char* current = combined_path;
 
     /* Copy parent path */
-    for (i=0; i<GUAC_RDP_FS_MAX_PATH; i++) {
-
-        char c = *(parent++);
-        if (c == 0)
-            break;
-
-        *(current++) = c;
-
-    }
+    length = guac_strlcpy(combined_path, parent, sizeof(combined_path));
 
     /* Add trailing slash */
-    *(current++) = '\\';
+    length += guac_strlcpy(combined_path + length, "\\",
+            sizeof(combined_path) - length);
 
     /* Copy remaining path */
-    strncpy(current, rel_path, GUAC_RDP_FS_MAX_PATH-i-2);
+    length += guac_strlcpy(combined_path + length, rel_path,
+            sizeof(combined_path) - length);
 
     /* Normalize into provided buffer */
     return guac_rdp_fs_normalize_path(combined_path, abs_path);
diff --git a/src/protocols/rdp/rdp_fs.h b/src/protocols/rdp/rdp_fs.h
index 8a754fb..e8299ef 100644
--- a/src/protocols/rdp/rdp_fs.h
+++ b/src/protocols/rdp/rdp_fs.h
@@ -51,6 +51,11 @@
 #define GUAC_RDP_FS_MAX_PATH 4096
 
 /**
+ * The maximum number of directories a path may contain.
+ */
+#define GUAC_RDP_MAX_PATH_DEPTH 64
+
+/**
  * Error code returned when no more file IDs can be allocated.
  */
 #define GUAC_RDP_FS_ENFILE -1
diff --git a/src/protocols/rdp/rdp_settings.c b/src/protocols/rdp/rdp_settings.c
index d46cd27..2a21ecb 100644
--- a/src/protocols/rdp/rdp_settings.c
+++ b/src/protocols/rdp/rdp_settings.c
@@ -27,6 +27,7 @@
 
 #include <freerdp/constants.h>
 #include <freerdp/settings.h>
+#include <guacamole/string.h>
 #include <guacamole/user.h>
 
 #ifdef ENABLE_WINPR
@@ -1268,11 +1269,11 @@
     /* Client name */
     if (guac_settings->client_name != NULL) {
 #ifdef LEGACY_RDPSETTINGS
-        strncpy(rdp_settings->client_hostname, guac_settings->client_name,
-                RDP_CLIENT_HOSTNAME_SIZE - 1);
+        guac_strlcpy(rdp_settings->client_hostname, guac_settings->client_name,
+                RDP_CLIENT_HOSTNAME_SIZE);
 #else
-        strncpy(rdp_settings->ClientHostname, guac_settings->client_name,
-                RDP_CLIENT_HOSTNAME_SIZE - 1);
+        guac_strlcpy(rdp_settings->ClientHostname, guac_settings->client_name,
+                RDP_CLIENT_HOSTNAME_SIZE);
 #endif
     }
 
diff --git a/src/protocols/rdp/rdp_stream.c b/src/protocols/rdp/rdp_stream.c
index 5dae366..533ff07 100644
--- a/src/protocols/rdp/rdp_stream.c
+++ b/src/protocols/rdp/rdp_stream.c
@@ -32,6 +32,7 @@
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
 #include <guacamole/stream.h>
+#include <guacamole/string.h>
 
 #ifdef HAVE_FREERDP_CLIENT_CLIPRDR_H
 #include <freerdp/client/cliprdr.h>
@@ -504,8 +505,8 @@
         rdp_stream->type = GUAC_RDP_LS_STREAM;
         rdp_stream->ls_status.fs = fs;
         rdp_stream->ls_status.file_id = file_id;
-        strncpy(rdp_stream->ls_status.directory_name, name,
-                sizeof(rdp_stream->ls_status.directory_name) - 1);
+        guac_strlcpy(rdp_stream->ls_status.directory_name, name,
+                sizeof(rdp_stream->ls_status.directory_name));
 
         /* Allocate stream for body */
         guac_stream* stream = guac_user_alloc_stream(user);
diff --git a/src/protocols/rdp/rdp_svc.c b/src/protocols/rdp/rdp_svc.c
index 83537a8..0a6eb24 100644
--- a/src/protocols/rdp/rdp_svc.c
+++ b/src/protocols/rdp/rdp_svc.c
@@ -25,6 +25,7 @@
 
 #include <freerdp/utils/svc_plugin.h>
 #include <guacamole/client.h>
+#include <guacamole/string.h>
 
 #ifdef ENABLE_WINPR
 #include <winpr/stream.h>
@@ -33,7 +34,6 @@
 #endif
 
 #include <stdlib.h>
-#include <string.h>
 
 guac_rdp_svc* guac_rdp_alloc_svc(guac_client* client, char* name) {
 
@@ -44,16 +44,14 @@
     svc->plugin = NULL;
     svc->output_pipe = NULL;
 
+    /* Init name */
+    int name_length = guac_strlcpy(svc->name, name, GUAC_RDP_SVC_MAX_LENGTH);
+
     /* Warn about name length */
-    if (strnlen(name, GUAC_RDP_SVC_MAX_LENGTH+1) > GUAC_RDP_SVC_MAX_LENGTH)
+    if (name_length >= GUAC_RDP_SVC_MAX_LENGTH)
         guac_client_log(client, GUAC_LOG_INFO,
                 "Static channel name \"%s\" exceeds maximum of %i characters "
-                "and will be truncated",
-                name, GUAC_RDP_SVC_MAX_LENGTH);
-
-    /* Init name */
-    strncpy(svc->name, name, GUAC_RDP_SVC_MAX_LENGTH);
-    svc->name[GUAC_RDP_SVC_MAX_LENGTH] = '\0';
+                "and will be truncated", name, GUAC_RDP_SVC_MAX_LENGTH - 1);
 
     return svc;
 }
diff --git a/src/protocols/rdp/rdp_svc.h b/src/protocols/rdp/rdp_svc.h
index 322c4d9..ebb3a13 100644
--- a/src/protocols/rdp/rdp_svc.h
+++ b/src/protocols/rdp/rdp_svc.h
@@ -27,9 +27,10 @@
 #include <guacamole/stream.h>
 
 /**
- * The maximum number of characters to allow for each channel name.
+ * The maximum number of bytes to allow within each channel name, including
+ * null terminator.
  */
-#define GUAC_RDP_SVC_MAX_LENGTH 7
+#define GUAC_RDP_SVC_MAX_LENGTH 8
 
 /**
  * Structure describing a static virtual channel, and the corresponding
@@ -50,7 +51,7 @@
     /**
      * The name of the RDP channel in use, and the name to use for each pipe.
      */
-    char name[GUAC_RDP_SVC_MAX_LENGTH+1];
+    char name[GUAC_RDP_SVC_MAX_LENGTH];
 
     /**
      * The output pipe, opened when the RDP server receives a connection to
diff --git a/src/protocols/rdp/tests/Makefile.am b/src/protocols/rdp/tests/Makefile.am
new file mode 100644
index 0000000..a803b63
--- /dev/null
+++ b/src/protocols/rdp/tests/Makefile.am
@@ -0,0 +1,64 @@
+#
+# 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 RDP support
+#
+
+check_PROGRAMS = test_rdp
+TESTS = $(check_PROGRAMS)
+
+test_rdp_SOURCES =      \
+    fs/normalize_path.c
+
+test_rdp_CFLAGS =                \
+    -Werror -Wall -pedantic      \
+    @LIBGUAC_CLIENT_RDP_INCLUDE@
+
+test_rdp_LDADD =               \
+    @CUNIT_LIBS@               \
+    @LIBGUAC_CLIENT_RDP_LTLIB@
+
+#
+# Autogenerate test runner
+#
+
+GEN_RUNNER = $(top_srcdir)/util/generate-test-runner.pl
+CLEANFILES = _generated_runner.c
+
+_generated_runner.c: $(test_rdp_SOURCES)
+	$(AM_V_GEN) $(GEN_RUNNER) $(test_rdp_SOURCES) > $@
+
+nodist_test_rdp_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/protocols/rdp/tests/fs/normalize_path.c b/src/protocols/rdp/tests/fs/normalize_path.c
new file mode 100644
index 0000000..ccf23e0
--- /dev/null
+++ b/src/protocols/rdp/tests/fs/normalize_path.c
@@ -0,0 +1,256 @@
+/*
+ * 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 "rdp_fs.h"
+
+#include <CUnit/CUnit.h>
+#include <stdlib.h>
+
+/**
+ * Test which verifies absolute Windows-style paths are correctly normalized to
+ * absolute paths with Windows separators and no relative components.
+ */
+void test_fs__normalize_absolute_windows() {
+
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo\\bar\\baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\bar\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo\\bar\\..\\baz\\", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo\\bar\\..\\..\\baz\\a\\..\\b", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\baz\\b", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo\\.\\bar\\baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\bar\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo\\bar\\..\\..\\..\\..\\..\\..\\baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\baz", sizeof(normalized));
+
+}
+
+/**
+ * Test which verifies absolute UNIX-style paths are correctly normalized to
+ * absolute paths with Windows separators and no relative components.
+ */
+void test_fs__normalize_absolute_unix() {
+
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/foo/bar/baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\bar\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/foo/bar/../baz/", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/foo/bar/../../baz/a/../b", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\baz\\b", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/foo/./bar/baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\bar\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/foo/bar/../../../../../../baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\baz", sizeof(normalized));
+
+}
+
+/**
+ * Test which verifies absolute paths consisting of mixed Windows and UNIX path
+ * separators are correctly normalized to absolute paths with Windows
+ * separators and no relative components.
+ */
+void test_fs__normalize_absolute_mixed() {
+
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo/bar\\baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\bar\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("/foo\\bar/..\\baz/", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo/bar\\../../baz\\a\\..\\b", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\baz\\b", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo\\.\\bar/baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\foo\\bar\\baz", sizeof(normalized));
+
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path("\\foo/bar\\../..\\..\\..\\../..\\baz", normalized), 0)
+    CU_ASSERT_NSTRING_EQUAL(normalized, "\\baz", sizeof(normalized));
+
+}
+
+/**
+ * Test which verifies relative Windows-style paths are always rejected.
+ */
+void test_fs__normalize_relative_windows() {
+
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(".", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("..", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("foo", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(".\\foo", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("..\\foo", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("foo\\bar\\baz", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(".\\foo\\bar\\baz", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("..\\foo\\bar\\baz", normalized), 0)
+
+}
+
+/**
+ * Test which verifies relative UNIX-style paths are always rejected.
+ */
+void test_fs__normalize_relative_unix() {
+
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(".", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("..", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("foo", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("./foo", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("../foo", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("foo/bar/baz", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("./foo/bar/baz", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("../foo/bar/baz", normalized), 0)
+
+}
+
+/**
+ * Test which verifies relative paths consisting of mixed Windows and UNIX path
+ * separators are always rejected.
+ */
+void test_fs__normalize_relative_mixed() {
+
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("foo\\bar/baz", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(".\\foo/bar/baz", normalized), 0)
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path("../foo\\bar\\baz", normalized), 0)
+
+}
+
+/**
+ * Generates a dynamically-allocated path having the given number of bytes, not
+ * counting the null-terminator. The path will contain only Windows-style path
+ * separators. The returned path must eventually be freed with a call to
+ * free().
+ *
+ * @param length
+ *     The number of bytes to include in the generated path, not counting the
+ *     null-terminator. If -1, the length of the path will be automatically
+ *     determined from the provided max_depth.
+ *
+ * @param max_depth
+ *     The maximum number of path components to include within the generated
+ *     path.
+ *
+ * @return
+ *     A dynamically-allocated path containing the given number of bytes, not
+ *     counting the null-terminator. This path must eventually be freed with a
+ *     call to free().
+ */
+static char* generate_path(int length, int max_depth) {
+
+    /* If no length given, calculate space required from max_depth */
+    if (length == -1)
+        length = max_depth * 2;
+
+    int i;
+    char* input = malloc(length + 1);
+
+    /* Fill path with \x\x\x\x\x\x\x\x\x\x\...\xxxxxxxxx... */
+    for (i = 0; i < length; i++) {
+        if (max_depth > 0 && i % 2 == 0) {
+            input[i] = '\\';
+            max_depth--;
+        }
+        else
+            input[i] = 'x';
+    }
+
+    /* Add null terminator */
+    input[length] = '\0';
+
+    return input;
+
+}
+
+/**
+ * Test which verifies that paths exceeding the maximum path length are
+ * rejected.
+ */
+void test_fs__normalize_long() {
+
+    char* input;
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    /* Exceeds maximum length by a factor of 2 */
+    input = generate_path(GUAC_RDP_FS_MAX_PATH * 2, GUAC_RDP_MAX_PATH_DEPTH);
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(input, normalized), 0);
+    free(input);
+
+    /* Exceeds maximum length by one byte */
+    input = generate_path(GUAC_RDP_FS_MAX_PATH, GUAC_RDP_MAX_PATH_DEPTH);
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(input, normalized), 0);
+    free(input);
+
+    /* Exactly maximum length */
+    input = generate_path(GUAC_RDP_FS_MAX_PATH - 1, GUAC_RDP_MAX_PATH_DEPTH);
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path(input, normalized), 0);
+    free(input);
+
+}
+
+/**
+ * Test which verifies that paths exceeding the maximum path depth are
+ * rejected.
+ */
+void test_fs__normalize_deep() {
+
+    char* input;
+    char normalized[GUAC_RDP_FS_MAX_PATH];
+
+    /* Exceeds maximum depth by a factor of 2 */
+    input = generate_path(-1, GUAC_RDP_MAX_PATH_DEPTH * 2);
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(input, normalized), 0);
+    free(input);
+
+    /* Exceeds maximum depth by one component */
+    input = generate_path(-1, GUAC_RDP_MAX_PATH_DEPTH + 1);
+    CU_ASSERT_NOT_EQUAL(guac_rdp_fs_normalize_path(input, normalized), 0);
+    free(input);
+
+    /* Exactly maximum depth */
+    input = generate_path(-1, GUAC_RDP_MAX_PATH_DEPTH);
+    CU_ASSERT_EQUAL(guac_rdp_fs_normalize_path(input, normalized), 0);
+    free(input);
+
+}
+
diff --git a/src/protocols/ssh/.gitignore b/src/protocols/ssh/.gitignore
deleted file mode 100644
index 3f72533..0000000
--- a/src/protocols/ssh/.gitignore
+++ /dev/null
@@ -1,36 +0,0 @@
-
-# Object code
-*.o
-*.so
-*.lo
-*.la
-
-# Backup files
-*~
-
-# Release files
-*.tar.gz
-
-# Files currently being edited by vim or vi
-*.swp
-
-# automake/autoconf
-.deps/
-.libs/
-Makefile
-Makefile.in
-aclocal.m4
-autom4te.cache/
-m4/*
-!README
-config.guess
-config.log
-config.status
-config.sub
-configure
-depcomp
-install-sh
-libtool
-ltmain.sh
-missing
-
diff --git a/src/protocols/telnet/.gitignore b/src/protocols/telnet/.gitignore
deleted file mode 100644
index 3f72533..0000000
--- a/src/protocols/telnet/.gitignore
+++ /dev/null
@@ -1,36 +0,0 @@
-
-# Object code
-*.o
-*.so
-*.lo
-*.la
-
-# Backup files
-*~
-
-# Release files
-*.tar.gz
-
-# Files currently being edited by vim or vi
-*.swp
-
-# automake/autoconf
-.deps/
-.libs/
-Makefile
-Makefile.in
-aclocal.m4
-autom4te.cache/
-m4/*
-!README
-config.guess
-config.log
-config.status
-config.sub
-configure
-depcomp
-install-sh
-libtool
-ltmain.sh
-missing
-
diff --git a/src/protocols/vnc/.gitignore b/src/protocols/vnc/.gitignore
deleted file mode 100644
index 3f72533..0000000
--- a/src/protocols/vnc/.gitignore
+++ /dev/null
@@ -1,36 +0,0 @@
-
-# Object code
-*.o
-*.so
-*.lo
-*.la
-
-# Backup files
-*~
-
-# Release files
-*.tar.gz
-
-# Files currently being edited by vim or vi
-*.swp
-
-# automake/autoconf
-.deps/
-.libs/
-Makefile
-Makefile.in
-aclocal.m4
-autom4te.cache/
-m4/*
-!README
-config.guess
-config.log
-config.status
-config.sub
-configure
-depcomp
-install-sh
-libtool
-ltmain.sh
-missing
-
diff --git a/util/generate-test-runner.pl b/util/generate-test-runner.pl
index 7534f80..ea99f7e 100755
--- a/util/generate-test-runner.pl
+++ b/util/generate-test-runner.pl
@@ -1,4 +1,4 @@
-#!/usr/bin/perl
+#!/usr/bin/env perl
 #
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file