diff --git a/README.markdown b/README.markdown
index 38dbd5bc..abceb7e8 100644
--- a/README.markdown
+++ b/README.markdown
@@ -27,6 +27,7 @@ php-memcached 3.x:
* Requires libmemcached 1.x or higher.
* Optionally supports igbinary 2.0 or higher.
* Optionally supports msgpack 2.0 or higher.
+* Optionally supports phpser 0.9.0 or higher (PHP 8.2 or higher).
php-memcached 2.x:
* Supports PHP 5.2 - 5.6.
@@ -50,3 +51,32 @@ representation that is interoperable with msgpack implementations for other
languages. When installing php-memcached from source code, the msgpack module
must be installed first so that php-memcached can access its C header files.
Load both modules in your `php.ini` at runtime to begin using msgpack.
+
+[phpser](https://github.com/iliaal/phpser) is a binary serializer for PHP
+data structures that can sign what it writes. Build php-memcached with
+`--enable-memcached-phpser` after installing phpser, and load phpser before
+memcached in your `php.ini` (`extension=phpser` above `extension=memcached`).
+Select it with `Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHPSER` or
+`memcached.serializer = "phpser"`.
+
+Without `phpser.key`, phpser behaves like igbinary: arrays, objects, and null
+go through phpser, while strings, integers, floats, and booleans are stored
+natively.
+
+When `phpser.key` is set to a key of at least 32 bytes, values are HMAC-signed
+for the `memcached` context and verified before decoding, so a client that can
+write to the memcached servers cannot make the reader instantiate objects.
+In this mode:
+
+* Strings, booleans, null, arrays, and objects are stored as signed phpser
+ values. Integers and floats stay native, so `increment()` and `decrement()`
+ keep working.
+* Reads accept only signed phpser values that verify, plus native integers and
+ floats. Any other item, including values written by other serializers, plain
+ strings, and items created by `increment()` with an initial value, reads back
+ as `false` with a warning, like a value that fails to unserialize.
+* `append()` and `prepend()` are refused with a warning, because they would
+ break the signature.
+
+A non-empty `phpser.key` shorter than 32 bytes also enables this mode, and
+then every phpser write and read fails.
diff --git a/composer.json b/composer.json
index 3356c89d..d9822ae3 100644
--- a/composer.json
+++ b/composer.json
@@ -8,7 +8,8 @@
},
"suggest": {
"ext-igbinary": "igbinary is a faster and more compact binary serializer for PHP data structures.",
- "ext-msgpack": "msgpack is a faster and more compact data structure representation that is interoperable with msgpack implementations for other languages."
+ "ext-msgpack": "msgpack is a faster and more compact data structure representation that is interoperable with msgpack implementations for other languages.",
+ "ext-phpser": "phpser is a binary serializer for PHP data structures that can sign values with phpser.key."
},
"php-ext": {
"extension-name": "memcached",
@@ -30,6 +31,10 @@
"name": "enable-memcached-igbinary",
"description": "Enable memcached igbinary serializer support"
},
+ {
+ "name": "enable-memcached-phpser",
+ "description": "Enable memcached phpser serializer support"
+ },
{
"name": "enable-memcached-json",
"description": "Enable memcached json serializer support"
diff --git a/config.m4 b/config.m4
index 0e4ef8cf..d711e889 100644
--- a/config.m4
+++ b/config.m4
@@ -12,6 +12,9 @@ PHP_ARG_ENABLE(memcached-session, whether to enable memcached session handler su
PHP_ARG_ENABLE(memcached-igbinary, whether to enable memcached igbinary serializer support,
[ --enable-memcached-igbinary Enable memcached igbinary serializer support], no, no)
+PHP_ARG_ENABLE(memcached-phpser, whether to enable memcached phpser serializer support,
+[ --enable-memcached-phpser Enable memcached phpser serializer support], no, no)
+
PHP_ARG_ENABLE(memcached-json, whether to enable memcached json serializer support,
[ --enable-memcached-json Enable memcached json serializer support], no, no)
@@ -160,6 +163,31 @@ if test "$PHP_MEMCACHED" != "no"; then
fi
fi
+ if test "$PHP_MEMCACHED_PHPSER" != "no"; then
+ AC_MSG_CHECKING([for phpser includes])
+ phpser_inc_path=""
+
+ if test -f "$abs_srcdir/include/php/ext/phpser/php_phpser_api.h"; then
+ phpser_inc_path="$abs_srcdir/include/php"
+ elif test -f "$abs_srcdir/ext/phpser/php_phpser_api.h"; then
+ phpser_inc_path="$abs_srcdir"
+ elif test -f "$phpincludedir/ext/phpser/php_phpser_api.h"; then
+ phpser_inc_path="$phpincludedir"
+ else
+ for i in php php4 php5 php6; do
+ if test -f "$prefix/include/$i/ext/phpser/php_phpser_api.h"; then
+ phpser_inc_path="$prefix/include/$i"
+ fi
+ done
+ fi
+
+ if test "$phpser_inc_path" = ""; then
+ AC_MSG_ERROR([Cannot find php_phpser_api.h])
+ else
+ AC_MSG_RESULT([$phpser_inc_path])
+ fi
+ fi
+
if test "$PHP_MEMCACHED_MSGPACK" != "no"; then
AC_MSG_CHECKING([for msgpack includes])
msgpack_inc_path=""
@@ -215,6 +243,20 @@ if test "$PHP_MEMCACHED" != "no"; then
AC_MSG_RESULT([disabled])
fi
+ AC_MSG_CHECKING([for memcached phpser support])
+ if test "$PHP_MEMCACHED_PHPSER" != "no"; then
+ AC_MSG_RESULT([enabled])
+ AC_DEFINE(HAVE_MEMCACHED_PHPSER,1,[Whether memcache phpser serializer is enabled])
+ PHPSER_INCLUDES="-I$phpser_inc_path"
+ ifdef([PHP_ADD_EXTENSION_DEP],
+ [
+ PHP_ADD_EXTENSION_DEP(memcached, phpser)
+ ])
+ else
+ PHPSER_INCLUDES=""
+ AC_MSG_RESULT([disabled])
+ fi
+
AC_MSG_CHECKING([for memcached msgpack support])
if test "$PHP_MEMCACHED_MSGPACK" != "no"; then
AC_MSG_RESULT([enabled])
@@ -405,7 +447,7 @@ if test "$PHP_MEMCACHED" != "no"; then
export PKG_CONFIG_PATH="$ORIG_PKG_CONFIG_PATH"
PHP_SUBST(MEMCACHED_SHARED_LIBADD)
- PHP_NEW_EXTENSION(memcached, $PHP_MEMCACHED_FILES, $ext_shared,,$SESSION_INCLUDES $IGBINARY_INCLUDES $LIBEVENT_INCLUDES $MSGPACK_INCLUDES)
+ PHP_NEW_EXTENSION(memcached, $PHP_MEMCACHED_FILES, $ext_shared,,$SESSION_INCLUDES $IGBINARY_INCLUDES $PHPSER_INCLUDES $LIBEVENT_INCLUDES $MSGPACK_INCLUDES)
if test "ac_cv_have_fastlz" != "yes"; then
PHP_ADD_BUILD_DIR($ext_builddir/fastlz, 1)
fi
diff --git a/config.w32 b/config.w32
index 76d4ee3c..e1a3ee6c 100644
--- a/config.w32
+++ b/config.w32
@@ -4,6 +4,7 @@ ARG_ENABLE('memcached', 'libmemcached extension', 'no');
ARG_ENABLE('memcached-session', 'whether to enable memcached session handler support', 'no');
ARG_ENABLE('memcached-igbinary', 'whether to enable memcached igbinary serializer support', 'no');
+ARG_ENABLE('memcached-phpser', 'whether to enable memcached phpser serializer support', 'no');
ARG_ENABLE('memcached-json', 'whether to enable memcached json serializer support', 'no');
ARG_ENABLE('memcached-msgpack', 'whether to enable memcached msgpack serializer support', 'no');
@@ -36,6 +37,17 @@ if (PHP_MEMCACHED == "yes") {
ERROR("memcached: header 'igbinary.h' not found");
}
}
+ if (PHP_MEMCACHED_PHPSER != "no"){
+ AC_DEFINE("HAVE_MEMCACHED_PHPSER",1);
+ ADD_EXTENSION_DEP("memcached", "phpser", true);
+ if (!CHECK_HEADER_ADD_INCLUDE("php_phpser_api.h", "CFLAGS_MEMCACHED", configure_module_dirname + "\\..\\phpser")) {
+ ERROR("memcached: header 'php_phpser_api.h' not found");
+ }
+ // php_phpser_api.h selects dllimport only when COMPILE_DL_PHPSER is defined.
+ if (typeof(PHP_PHPSER_SHARED) != "undefined" && PHP_PHPSER_SHARED) {
+ ADD_FLAG("CFLAGS_MEMCACHED", "/D COMPILE_DL_PHPSER");
+ }
+ }
if (PHP_MEMCACHED_MSGPACK != "no"){
AC_DEFINE("HAVE_MEMCACHED_MSGPACK",1);
ADD_EXTENSION_DEP("memcached", "msgpack", true);
diff --git a/memcached-api.php b/memcached-api.php
index 8ab03d4c..ade8b952 100644
--- a/memcached-api.php
+++ b/memcached-api.php
@@ -96,6 +96,8 @@ class Memcached {
const HAVE_MSGPACK;
+ const HAVE_PHPSER;
+
const HAVE_ENCODING;
/**
@@ -127,6 +129,8 @@ class Memcached {
const SERIALIZER_MSGPACK;
+ const SERIALIZER_PHPSER;
+
/**
* Compression types
*/
diff --git a/memcached.ini b/memcached.ini
index 5decf399..d26f5ca3 100644
--- a/memcached.ini
+++ b/memcached.ini
@@ -112,7 +112,7 @@
;memcached.compression_threshold = 2000
; Set the default serializer for new memcached objects.
-; valid values are: php, igbinary, json, json_array, msgpack
+; valid values are: php, igbinary, json, json_array, msgpack, phpser
;
; json - standard php JSON encoding. This serializer
; is fast and compact but only works on UTF-8
@@ -122,6 +122,8 @@
; php - the standard php serializer
; igbinary - a binary serializer
; msgpack - a cross-language binary serializer
+; phpser - a binary serializer; with phpser.key set, values are signed and
+; only signed values, integers, and floats are read back
;
; The default is igbinary if available, then msgpack if available, then php otherwise.
;memcached.serializer = "igbinary"
diff --git a/package.xml b/package.xml
index 524a51e1..65856a91 100644
--- a/package.xml
+++ b/package.xml
@@ -142,6 +142,15 @@ http://pear.php.net/dtd/package-2.0.xsd">
+
+
+
+
+
+
+
+
+
@@ -206,6 +215,10 @@ http://pear.php.net/dtd/package-2.0.xsd">
msgpack
2.0
+
+ phpser
+ 0.9.0
+
memcached
@@ -216,6 +229,7 @@ http://pear.php.net/dtd/package-2.0.xsd">
+
diff --git a/php_memcached.c b/php_memcached.c
index 47ed2d33..5dc3dcd2 100644
--- a/php_memcached.c
+++ b/php_memcached.c
@@ -59,6 +59,16 @@
# include "ext/msgpack/php_msgpack.h"
#endif
+#ifdef HAVE_MEMCACHED_PHPSER
+# include "ext/standard/php_versioning.h"
+#ifdef PHP_WIN32
+# include "php_phpser_api.h"
+#else
+# include "ext/phpser/php_phpser_api.h"
+#endif
+# define MEMC_PHPSER_CONTEXT "memcached"
+#endif
+
# include "ext/spl/spl_exceptions.h"
static int le_memc;
@@ -109,6 +119,7 @@ static int php_memc_list_entry(void) {
#define MEMC_VAL_IS_IGBINARY 5
#define MEMC_VAL_IS_JSON 6
#define MEMC_VAL_IS_MSGPACK 7
+#define MEMC_VAL_IS_PHPSER 8
#define MEMC_VAL_COMPRESSED (1<<0)
#define MEMC_VAL_COMPRESSION_ZLIB (1<<1)
@@ -336,6 +347,10 @@ static PHP_INI_MH(OnUpdateSerializer)
} else if (!strcmp(ZSTR_VAL(new_value), "msgpack")) {
MEMC_G(serializer_type) = SERIALIZER_MSGPACK;
#endif // msgpack
+#ifdef HAVE_MEMCACHED_PHPSER
+ } else if (!strcmp(ZSTR_VAL(new_value), "phpser")) {
+ MEMC_G(serializer_type) = SERIALIZER_PHPSER;
+#endif // phpser
} else {
return FAILURE;
}
@@ -902,6 +917,31 @@ zend_bool s_invoke_cache_callback(zval *zobject, zend_fcall_info *fci, zend_fcal
Wrapper for setting from zval
****************************************/
+#ifdef HAVE_MEMCACHED_PHPSER
+/* phpser has no API that reports whether signing is active, so apply its own
+ * rule: a non-empty phpser.key means values must verify. A key shorter than
+ * phpser accepts also counts, because phpser then rejects every value. */
+static
+zend_bool s_phpser_key_set(void)
+{
+ zend_ini_entry *entry = zend_hash_str_find_ptr(EG(ini_directives), "phpser.key", sizeof("phpser.key") - 1);
+
+ return entry && entry->value && ZSTR_LEN(entry->value) > 0;
+}
+#endif
+
+/* With signed phpser, only verified phpser items and native integers and
+ * floats are read back; every other value type is written through phpser. */
+static
+zend_bool s_phpser_keyed(php_memc_serializer_type serializer)
+{
+#ifdef HAVE_MEMCACHED_PHPSER
+ return serializer == SERIALIZER_PHPSER && s_phpser_key_set();
+#else
+ return 0;
+#endif
+}
+
static
zend_bool s_compress_value (php_memc_compression_type compression_type, zend_long compression_level, zend_string **payload_in, uint32_t *flags)
{
@@ -1026,6 +1066,25 @@ zend_bool s_serialize_value (php_memc_serializer_type serializer, zval *value, s
break;
#endif
+ /*
+ phpser serialization
+ */
+#ifdef HAVE_MEMCACHED_PHPSER
+ case SERIALIZER_PHPSER:
+ {
+ zend_string *encoded;
+
+ /* phpser raises a warning or an exception on failure */
+ if (phpser_api_serialize(value, MEMC_PHPSER_CONTEXT, sizeof(MEMC_PHPSER_CONTEXT) - 1, &encoded) != PHPSER_API_OK) {
+ return 0;
+ }
+ smart_str_append(buf, encoded);
+ zend_string_release(encoded);
+ MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_PHPSER);
+ }
+ break;
+#endif
+
/*
JSON serialization
*/
@@ -1079,16 +1138,33 @@ zend_bool s_serialize_value (php_memc_serializer_type serializer, zval *value, s
return 1;
}
+static
+zend_string *s_serialize_payload(php_memc_serializer_type serializer, zval *value, uint32_t *flags)
+{
+ smart_str buffer = {0};
+
+ if (!s_serialize_value (serializer, value, &buffer, flags)) {
+ smart_str_free(&buffer);
+ return NULL;
+ }
+ return buffer.s;
+}
+
static
zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t *flags)
{
zend_string *payload;
php_memc_user_data_t *memc_user_data = memcached_get_user_data(intern->memc);
zend_bool should_compress = memc_user_data->compression_enabled;
+ zend_bool phpser_keyed = s_phpser_keyed(memc_user_data->serializer);
switch (Z_TYPE_P(value)) {
case IS_STRING:
+ if (phpser_keyed) {
+ payload = s_serialize_payload(memc_user_data->serializer, value, flags);
+ break;
+ }
payload = zval_get_string(value);
MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_STRING);
break;
@@ -1116,30 +1192,34 @@ zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t
break;
case IS_TRUE:
+ if (phpser_keyed) {
+ payload = s_serialize_payload(memc_user_data->serializer, value, flags);
+ break;
+ }
payload = zend_string_init ("1", 1, 0);
MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_BOOL);
should_compress = 0;
break;
case IS_FALSE:
+ if (phpser_keyed) {
+ payload = s_serialize_payload(memc_user_data->serializer, value, flags);
+ break;
+ }
payload = zend_string_alloc (0, 0);
MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_BOOL);
should_compress = 0;
break;
default:
- {
- smart_str buffer = {0};
-
- if (!s_serialize_value (memc_user_data->serializer, value, &buffer, flags)) {
- smart_str_free(&buffer);
- return NULL;
- }
- payload = buffer.s;
- }
+ payload = s_serialize_payload(memc_user_data->serializer, value, flags);
break;
}
+ if (!payload) {
+ return NULL;
+ }
+
/* turn off compression for values below the threshold */
if (ZSTR_LEN(payload) == 0 || ZSTR_LEN(payload) < MEMC_G(compression_threshold)) {
should_compress = 0;
@@ -2150,6 +2230,14 @@ static void php_memc_store_impl(INTERNAL_FUNCTION_PARAMETERS, int op, zend_bool
}
}
+ if (s_phpser_keyed(memc_user_data->serializer)) {
+ /* Appending to a signed value breaks its tag, so it would read back as false */
+ if (op == MEMC_OP_APPEND || op == MEMC_OP_PREPEND) {
+ php_error_docref(NULL, E_WARNING, "cannot append/prepend with the phpser serializer when phpser.key is set");
+ RETURN_NULL();
+ }
+ }
+
if (!s_memc_write_zval (intern, op, server_key, key, value, expiration)) {
RETURN_FALSE;
}
@@ -3187,6 +3275,12 @@ int php_memc_set_option(php_memc_object_t *intern, long option, zval *value)
if (lval == SERIALIZER_MSGPACK) {
memc_user_data->serializer = SERIALIZER_MSGPACK;
} else
+#endif
+ /* phpser serializer */
+#ifdef HAVE_MEMCACHED_PHPSER
+ if (lval == SERIALIZER_PHPSER) {
+ memc_user_data->serializer = SERIALIZER_PHPSER;
+ } else
#endif
/* php serializer */
if (lval == SERIALIZER_PHP) {
@@ -3827,6 +3921,27 @@ zend_bool s_unserialize_value (memcached_st *memc, int val_type, zend_string *pa
#endif
break;
+ case MEMC_VAL_IS_PHPSER:
+#ifdef HAVE_MEMCACHED_PHPSER
+ {
+ int rc = phpser_api_unserialize(ZSTR_VAL(payload), ZSTR_LEN(payload), MEMC_PHPSER_CONTEXT, sizeof(MEMC_PHPSER_CONTEXT) - 1, return_value);
+
+ if (rc != PHPSER_API_OK) {
+ ZVAL_FALSE(return_value);
+ /* With phpser.key set, phpser itself reports every failure a forged value can cause */
+ if (rc == PHPSER_API_ERR && !EG(exception) && !s_phpser_key_set()) {
+ php_error_docref(NULL, E_WARNING, "could not unserialize value with phpser");
+ }
+ return 0;
+ }
+ }
+#else
+ ZVAL_FALSE(return_value);
+ php_error_docref(NULL, E_WARNING, "could not unserialize value, no phpser support");
+ return 0;
+#endif
+ break;
+
case MEMC_VAL_IS_JSON:
#ifdef HAVE_JSON_API
{
@@ -3861,6 +3976,7 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re
size_t payload_len;
uint32_t flags;
zend_bool retval = 1;
+ php_memc_user_data_t *memc_user_data = memcached_get_user_data(memc);
payload = memcached_result_value(result);
payload_len = memcached_result_length(result);
@@ -3871,6 +3987,22 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re
return 0;
}
+ /* The type comes from the stored flags, which whoever wrote the item chose,
+ * so the configured serializer alone does not keep other decoders out. */
+ if (s_phpser_keyed(memc_user_data->serializer)) {
+ switch (MEMC_VAL_GET_TYPE(flags)) {
+ case MEMC_VAL_IS_PHPSER:
+ case MEMC_VAL_IS_LONG:
+ case MEMC_VAL_IS_DOUBLE:
+ break;
+
+ default:
+ php_error_docref(NULL, E_WARNING, "could not unserialize value: phpser.key is set and the value is not signed phpser");
+ ZVAL_FALSE(return_value);
+ return 0;
+ }
+ }
+
if (MEMC_VAL_HAS_FLAG(flags, MEMC_VAL_COMPRESSED)) {
data = s_decompress_value (payload, payload_len, flags);
if (!data) {
@@ -3915,6 +4047,7 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re
case MEMC_VAL_IS_IGBINARY:
case MEMC_VAL_IS_JSON:
case MEMC_VAL_IS_MSGPACK:
+ case MEMC_VAL_IS_PHPSER:
retval = s_unserialize_value (memc, MEMC_VAL_GET_TYPE(flags), data, return_value);
break;
@@ -4056,6 +4189,9 @@ static const zend_module_dep memcached_deps[] = {
#endif
#ifdef HAVE_MEMCACHED_MSGPACK
ZEND_MOD_REQUIRED("msgpack")
+#endif
+#ifdef HAVE_MEMCACHED_PHPSER
+ ZEND_MOD_REQUIRED("phpser")
#endif
ZEND_MOD_REQUIRED("spl")
ZEND_MOD_END
@@ -4187,6 +4323,15 @@ static void php_memc_register_constants(INIT_FUNC_ARGS)
REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_MSGPACK, 0);
#endif
+ /*
+ * Indicate whether phpser serializer is available
+ */
+#ifdef HAVE_MEMCACHED_PHPSER
+ REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_PHPSER, 1);
+#else
+ REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_PHPSER, 0);
+#endif
+
/*
* Indicate whether set_encoding_key is available
*/
@@ -4334,6 +4479,7 @@ static void php_memc_register_constants(INIT_FUNC_ARGS)
REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_JSON, SERIALIZER_JSON);
REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_JSON_ARRAY, SERIALIZER_JSON_ARRAY);
REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_MSGPACK, SERIALIZER_MSGPACK);
+ REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_PHPSER, SERIALIZER_PHPSER);
/*
* Compression types
@@ -4398,11 +4544,43 @@ static void php_memc_register_constants(INIT_FUNC_ARGS)
}
/* }}} */
+#ifdef HAVE_MEMCACHED_PHPSER
+/* phpser_api_version() does not exist before phpser 0.9.0, so check the
+ * module version before calling into the API. */
+static
+int php_memc_check_phpser(void)
+{
+ const char *version = zend_get_module_version("phpser");
+
+ if (version == NULL || php_version_compare(version, "0.9.0") < 0) {
+ php_error_docref(NULL, E_CORE_WARNING,
+ "Memcached::SERIALIZER_PHPSER requires phpser 0.9.0 or later, found %s",
+ version ? version : "none");
+ return FAILURE;
+ }
+
+ if (phpser_api_version() != PHPSER_API_VERSION) {
+ php_error_docref(NULL, E_CORE_WARNING,
+ "Memcached::SERIALIZER_PHPSER requires phpser API version %d, found %u",
+ PHPSER_API_VERSION, phpser_api_version());
+ return FAILURE;
+ }
+
+ return SUCCESS;
+}
+#endif
+
/* {{{ PHP_MINIT_FUNCTION */
PHP_MINIT_FUNCTION(memcached)
{
zend_class_entry ce;
+#ifdef HAVE_MEMCACHED_PHPSER
+ if (php_memc_check_phpser() == FAILURE) {
+ return FAILURE;
+ }
+#endif
+
memcpy(&memcached_object_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
memcached_object_handlers.offset = offsetof(php_memc_object_t, zo);
memcached_object_handlers.clone_obj = NULL;
@@ -4506,6 +4684,12 @@ PHP_MINFO_FUNCTION(memcached)
php_info_print_table_row(2, "msgpack support", "no");
#endif
+#ifdef HAVE_MEMCACHED_PHPSER
+ php_info_print_table_row(2, "phpser support", "yes");
+#else
+ php_info_print_table_row(2, "phpser support", "no");
+#endif
+
#ifdef HAVE_ZSTD_H
php_info_print_table_row(2, "zstd support", "yes");
#else
diff --git a/php_memcached_private.h b/php_memcached_private.h
index b7d2a5e1..8c2975e5 100644
--- a/php_memcached_private.h
+++ b/php_memcached_private.h
@@ -97,7 +97,8 @@ typedef enum {
SERIALIZER_IGBINARY = 2,
SERIALIZER_JSON = 3,
SERIALIZER_JSON_ARRAY = 4,
- SERIALIZER_MSGPACK = 5
+ SERIALIZER_MSGPACK = 5,
+ SERIALIZER_PHPSER = 6
} php_memc_serializer_type;
typedef enum {
diff --git a/tests/phpser.inc b/tests/phpser.inc
new file mode 100644
index 00000000..43f5fbb4
--- /dev/null
+++ b/tests/phpser.inc
@@ -0,0 +1,83 @@
+cmd = $data['cmd'] ?? 'none';
+ }
+
+ public function __destruct() {
+ self::$events[] = "destruct";
+ }
+}
+
+/* Writes an item with caller-chosen flags, as anyone with write access to
+ * the memcached server can. */
+function phpser_raw_set($key, $flags, $data)
+{
+ $fp = fsockopen(MEMC_SERVER_HOST, MEMC_SERVER_PORT, $errno, $errstr, 2);
+ fwrite($fp, "set $key $flags 0 " . strlen($data) . "\r\n" . $data . "\r\n");
+ $reply = trim(fgets($fp));
+ fclose($fp);
+ if ($reply !== "STORED") {
+ echo "raw set of $key failed: $reply\n";
+ }
+}
+
+/* Returns [flags, data] for a key, read without any client-side decoding. */
+function phpser_raw_get($key)
+{
+ $fp = fsockopen(MEMC_SERVER_HOST, MEMC_SERVER_PORT, $errno, $errstr, 2);
+ fwrite($fp, "get $key\r\n");
+ $header = trim(fgets($fp));
+ if ($header === "END") {
+ fclose($fp);
+ return null;
+ }
+ list(, , $flags, $len) = explode(' ', $header);
+ $data = '';
+ while (strlen($data) < $len) {
+ $data .= fread($fp, $len - strlen($data));
+ }
+ fclose($fp);
+ return [(int) $flags, $data];
+}
+
+function phpser_raw_type($key)
+{
+ $item = phpser_raw_get($key);
+ return $item === null ? null : ($item[0] & 0xf);
+}
+
+/* Signs a phpser frame the way phpser does for phpser.key = $key. */
+function phpser_forge_signed($value, $key, $context = 'memcached')
+{
+ $frame = phpser_serialize($value);
+ $subkey = hash_hmac('sha256', "phpser:" . $context . ":v1\0", $key, true);
+ return $frame . hash_hmac('sha256', $frame, $subkey, true);
+}
+
+function phpser_gadget_serialized()
+{
+ return 'O:12:"PhpserGadget":1:{s:3:"cmd";s:6:"rm -rf";}';
+}
diff --git a/tests/phpser_keyed.phpt b/tests/phpser_keyed.phpt
new file mode 100644
index 00000000..f3fa8be3
--- /dev/null
+++ b/tests/phpser_keyed.phpt
@@ -0,0 +1,106 @@
+--TEST--
+Memcached phpser serializer with phpser.key: stored types, counters, append/prepend
+--SKIPIF--
+
+--INI--
+phpser.key=memcached-phpser-test-key-0123456789
+--FILE--
+ Memcached::SERIALIZER_PHPSER,
+ Memcached::OPT_COMPRESSION => false,
+));
+
+echo "-- stored types\n";
+$values = array(
+ 'str' => 'hello',
+ 'empty' => '',
+ 'true' => true,
+ 'false' => false,
+ 'null' => null,
+ 'arr' => array(1, 'two' => 2),
+ 'obj' => (object) array('a' => 1),
+ 'long' => 42,
+ 'dbl' => 1.5,
+);
+foreach ($values as $key => $value) {
+ $m->set($key, $value);
+ $back = $m->get($key);
+ printf("%-5s type=%d same=%s\n", $key, phpser_raw_type($key),
+ var_export(is_object($value) ? $back == $value : $back === $value, true));
+}
+
+echo "-- signed bytes match phpser_serialize_keyed()\n";
+var_dump(phpser_raw_get('str')[1] === phpser_serialize_keyed('hello', 'memcached'));
+var_dump(phpser_raw_get('str')[1] === phpser_forge_signed('hello', PHPSER_TEST_KEY));
+
+echo "-- counters stay native\n";
+var_dump($m->set('counter', 10));
+var_dump($m->increment('counter', 5));
+var_dump($m->decrement('counter', 2));
+var_dump($m->get('counter'));
+var_dump(phpser_raw_type('counter'));
+
+echo "-- append/prepend are refused\n";
+$m->set('ap', 'abc');
+var_dump($m->append('ap', 'def'));
+var_dump($m->prepend('ap', 'xyz'));
+var_dump($m->appendByKey('', 'ap', 'def'));
+var_dump($m->get('ap'));
+
+echo "-- user flags are kept and do not affect the type\n";
+$m->setOption(Memcached::OPT_USER_FLAGS, 0xBEEF);
+$m->set('uf', array('x'));
+$r = $m->get('uf', null, Memcached::GET_EXTENDED);
+var_dump($r['value'] === array('x'), $r['flags'] === 0xBEEF, phpser_raw_type('uf'));
+
+echo "-- cas writes sign too\n";
+$r = $m->get('str', null, Memcached::GET_EXTENDED);
+var_dump($m->cas($r['cas'], 'str', 'replaced'));
+var_dump($m->get('str'), phpser_raw_type('str'));
+?>
+--EXPECTF--
+-- stored types
+str type=8 same=true
+empty type=8 same=true
+true type=8 same=true
+false type=8 same=true
+null type=8 same=true
+arr type=8 same=true
+obj type=8 same=true
+long type=1 same=true
+dbl type=2 same=true
+-- signed bytes match phpser_serialize_keyed()
+bool(true)
+bool(true)
+-- counters stay native
+bool(true)
+int(15)
+int(13)
+int(13)
+int(1)
+-- append/prepend are refused
+
+Warning: Memcached::append(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d
+NULL
+
+Warning: Memcached::prepend(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d
+NULL
+
+Warning: Memcached::appendByKey(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d
+NULL
+string(3) "abc"
+-- user flags are kept and do not affect the type
+bool(true)
+bool(true)
+int(8)
+-- cas writes sign too
+bool(true)
+string(8) "replaced"
+int(8)
diff --git a/tests/phpser_keyed_attack.phpt b/tests/phpser_keyed_attack.phpt
new file mode 100644
index 00000000..f8ed881c
--- /dev/null
+++ b/tests/phpser_keyed_attack.phpt
@@ -0,0 +1,110 @@
+--TEST--
+Memcached phpser serializer with phpser.key rejects items it did not sign
+--SKIPIF--
+
+--INI--
+phpser.key=memcached-phpser-test-key-0123456789
+--FILE--
+ Memcached::SERIALIZER_PHPSER));
+$php = memc_create_instance(MEMC_SERVER_HOST, MEMC_SERVER_PORT, array(Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHP));
+
+$gadget = new PhpserGadget();
+$gadget->cmd = 'rm -rf';
+$gadget_phpser = phpser_serialize($gadget);
+$gadget_igbinary = function_exists('igbinary_serialize') ? igbinary_serialize($gadget) : "\x00\x00\x00\x02\x17\x0cPhpserGadget";
+unset($gadget);
+
+$zlib = function ($data) {
+ return pack('V', strlen($data)) . gzcompress($data);
+};
+
+$signed = phpser_serialize_keyed(array('k' => str_repeat('v', 50)), 'memcached');
+$tampered = $signed;
+$tampered[10] = chr(ord($tampered[10]) ^ 1);
+
+$items = array(
+ 'ser_gadget' => array(MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()),
+ 'ser_gadget_zlib' => array(MEMC_TEST_IS_SERIALIZED | MEMC_TEST_COMPRESSED_ZLIB, $zlib(phpser_gadget_serialized())),
+ 'igbinary' => array(MEMC_TEST_IS_IGBINARY, $gadget_igbinary),
+ 'string' => array(MEMC_TEST_IS_STRING, 'attacker text'),
+ 'bool' => array(MEMC_TEST_IS_BOOL, '1'),
+ 'json' => array(MEMC_TEST_IS_JSON, '{"a":1}'),
+ 'msgpack' => array(MEMC_TEST_IS_MSGPACK, "\x81\xa1a\x01"),
+ 'unknown_type' => array(9, 'x'),
+ 'phpser_unsigned' => array(MEMC_TEST_IS_PHPSER, $gadget_phpser),
+ 'phpser_unsigned_zlib' => array(MEMC_TEST_IS_PHPSER | MEMC_TEST_COMPRESSED_ZLIB, $zlib($gadget_phpser)),
+ 'phpser_wrongkey' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('evil'), str_repeat('z', 32))),
+ 'phpser_ctx' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('evil'), PHPSER_TEST_KEY, 'redis')),
+ 'phpser_tampered' => array(MEMC_TEST_IS_PHPSER, $tampered),
+ 'phpser_truncated' => array(MEMC_TEST_IS_PHPSER, substr($signed, 0, -1)),
+ 'phpser_empty' => array(MEMC_TEST_IS_PHPSER, ''),
+ 'good' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('ok'), PHPSER_TEST_KEY)),
+ 'good_zlib' => array(MEMC_TEST_IS_PHPSER | MEMC_TEST_COMPRESSED_ZLIB, $zlib($signed)),
+ 'native_long' => array(MEMC_TEST_IS_LONG, '7'),
+ 'native_double' => array(MEMC_TEST_IS_DOUBLE, '2.5'),
+);
+
+foreach ($items as $key => $item) {
+ phpser_raw_set($key, $item[0], $item[1]);
+}
+
+/* Other serializers write into the same pool */
+$php->set('php_client_array', array('from' => 'php serializer'));
+$php->set('php_client_string', 'plain string');
+$php->set('php_client_bool', true);
+foreach (array('php_client_array', 'php_client_string', 'php_client_bool') as $key) {
+ $items[$key] = null;
+}
+
+PhpserGadget::$events = array();
+
+$warnings = array();
+set_error_handler(function ($no, $str) use (&$warnings) {
+ $warnings[] = preg_replace('/^Memcached::\w+\(\): /', '', $str);
+ return true;
+});
+
+foreach (array_keys($items) as $key) {
+ $warnings = array();
+ $value = $m->get($key);
+ printf("%-20s %s rc=%d warnings=%d%s\n", $key,
+ is_array($value) ? 'array(' . count($value) . ')' : var_export($value, true),
+ $m->getResultCode(), count($warnings), $warnings ? ' ' . $warnings[0] : '');
+}
+
+restore_error_handler();
+echo "gadget events: ", json_encode(PhpserGadget::$events), "\n";
+var_dump(Memcached::RES_SOME_ERRORS);
+?>
+--EXPECT--
+ser_gadget false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+ser_gadget_zlib false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+igbinary false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+string false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+bool false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+json false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+msgpack false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+unknown_type false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+phpser_unsigned false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+phpser_unsigned_zlib false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+phpser_wrongkey false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+phpser_ctx false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+phpser_tampered false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+phpser_truncated false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+phpser_empty false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded
+good array(1) rc=0 warnings=0
+good_zlib array(1) rc=0 warnings=0
+native_long 7 rc=0 warnings=0
+native_double 2.5 rc=0 warnings=0
+php_client_array false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+php_client_string false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+php_client_bool false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser
+gadget events: []
+int(19)
diff --git a/tests/phpser_keyed_incr_initial.phpt b/tests/phpser_keyed_incr_initial.phpt
new file mode 100644
index 00000000..0592ae34
--- /dev/null
+++ b/tests/phpser_keyed_incr_initial.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Memcached phpser serializer with phpser.key: items created by increment() with an initial value carry no type
+--SKIPIF--
+
+--INI--
+phpser.key=memcached-phpser-test-key-0123456789
+--FILE--
+ Memcached::SERIALIZER_PHPSER,
+ Memcached::OPT_BINARY_PROTOCOL => true,
+));
+
+/* The server creates the item with flags 0, the string type, so get()
+ * refuses it; increment() and decrement() keep working on it. */
+var_dump($m->increment('c', 1, 10));
+var_dump($m->increment('c', 5));
+var_dump(phpser_raw_type('c'));
+var_dump(@$m->get('c'));
+
+$m->set('c2', 10);
+var_dump($m->increment('c2', 1, 99));
+var_dump($m->get('c2'));
+?>
+--EXPECT--
+int(10)
+int(15)
+int(0)
+bool(false)
+int(11)
+int(11)
diff --git a/tests/phpser_keyed_multi.phpt b/tests/phpser_keyed_multi.phpt
new file mode 100644
index 00000000..16ba935e
--- /dev/null
+++ b/tests/phpser_keyed_multi.phpt
@@ -0,0 +1,99 @@
+--TEST--
+Memcached phpser serializer with phpser.key gates getMulti, getDelayed, fetch, byKey, and callbacks
+--SKIPIF--
+
+--INI--
+phpser.key=memcached-phpser-test-key-0123456789
+--FILE--
+ Memcached::SERIALIZER_PHPSER));
+
+$m->set('good1', array('one'));
+$m->set('good2', 'two');
+$m->set('num', 3);
+phpser_raw_set('evil_ser', MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized());
+phpser_raw_set('evil_str', MEMC_TEST_IS_STRING, 'forged');
+phpser_raw_set('evil_unsigned', MEMC_TEST_IS_PHPSER, phpser_serialize(array('forged')));
+PhpserGadget::$events = array();
+
+$keys = array('good1', 'evil_ser', 'good2', 'evil_str', 'num', 'evil_unsigned');
+
+/* One warning per rejected item, and the rejected values must not reach
+ * any caller, whatever the entry point. */
+$warnings = 0;
+set_error_handler(function () use (&$warnings) { $warnings++; return true; });
+
+function show($label, $value, $m, &$warnings) {
+ echo $label, ": ", json_encode($value), " rc=", $m->getResultCode(), " warnings=", $warnings, "\n";
+ $warnings = 0;
+}
+
+show('getMulti', $m->getMulti($keys), $m, $warnings);
+show('getMultiByKey', $m->getMultiByKey('srv', $keys), $m, $warnings);
+
+$ext = $m->getMulti($keys, Memcached::GET_EXTENDED);
+show('getMulti extended keys', array_keys($ext), $m, $warnings);
+
+show('getByKey evil', $m->getByKey('srv', 'evil_ser'), $m, $warnings);
+show('getByKey good', $m->getByKey('srv', 'good1'), $m, $warnings);
+show('get extended evil', $m->get('evil_str', null, Memcached::GET_EXTENDED), $m, $warnings);
+
+/* A decode failure makes fetch() return false and drops the next item,
+ * and makes fetchAll() return false; that is the existing contract for any
+ * value that does not unserialize. */
+$m->getDelayed($keys);
+$seq = array();
+for ($i = 0; $i < 5; $i++) {
+ $r = $m->fetch();
+ $seq[] = ($r ? $r['key'] . '=' . json_encode($r['value']) : 'false') . ' rc=' . $m->getResultCode();
+}
+show('getDelayed+fetch', $seq, $m, $warnings);
+
+$m->getDelayed($keys, true);
+show('getDelayed(cas)+fetchAll', $m->fetchAll(), $m, $warnings);
+
+$cb = array();
+$m->getDelayedByKey('srv', $keys, false, function ($memc, $r) use (&$cb) {
+ $cb[$r['key']] = $r['value'];
+});
+ksort($cb);
+show('getDelayedByKey callback', $cb, $m, $warnings);
+
+$called = 0;
+$v = $m->get('evil_ser', function ($memc, $key, &$value) use (&$called) {
+ $called++;
+ $value = 'from callback';
+ return true;
+});
+show('read-through on rejected item', array($v, $called), $m, $warnings);
+
+$v = $m->get('missing', function ($memc, $key, &$value) {
+ $value = 'from callback';
+ return true;
+});
+show('read-through on miss', $v, $m, $warnings);
+show('read-through value stored signed', array($m->get('missing'), phpser_raw_type('missing')), $m, $warnings);
+
+restore_error_handler();
+echo "gadget events: ", json_encode(PhpserGadget::$events), "\n";
+?>
+--EXPECT--
+getMulti: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3
+getMultiByKey: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3
+getMulti extended keys: ["good1","good2","num"] rc=19 warnings=3
+getByKey evil: false rc=19 warnings=1
+getByKey good: ["one"] rc=0 warnings=0
+get extended evil: false rc=19 warnings=1
+getDelayed+fetch: ["good1=[\"one\"] rc=0","false rc=19","false rc=19","false rc=19","false rc=16"] rc=16 warnings=3
+getDelayed(cas)+fetchAll: false rc=19 warnings=3
+getDelayedByKey callback: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3
+read-through on rejected item: [false,0] rc=19 warnings=1
+read-through on miss: "from callback" rc=0 warnings=0
+read-through value stored signed: ["from callback",8] rc=0 warnings=0
+gadget events: []
diff --git a/tests/phpser_short_key.phpt b/tests/phpser_short_key.phpt
new file mode 100644
index 00000000..87150724
--- /dev/null
+++ b/tests/phpser_short_key.phpt
@@ -0,0 +1,62 @@
+--TEST--
+Memcached phpser serializer rejects values when phpser.key is too short
+--SKIPIF--
+
+--INI--
+phpser.key=too-short
+--FILE--
+ Memcached::SERIALIZER_PHPSER));
+
+$warnings = array();
+set_error_handler(function ($no, $str) use (&$warnings) {
+ $warnings[] = preg_replace('/^Memcached::\w+\(\): /', '', $str);
+ return true;
+});
+
+function show($label, $value, $m, &$warnings) {
+ echo $label, ": ", var_export($value, true), " rc=", $m->getResultCode(), " ", json_encode($warnings), "\n";
+ $warnings = array();
+}
+
+show('set string', $m->set('s', 'value'), $m, $warnings);
+show('set array', $m->set('a', array(1)), $m, $warnings);
+show('set bool', $m->set('b', true), $m, $warnings);
+show('set long', $m->set('l', 5), $m, $warnings);
+show('get long', $m->get('l'), $m, $warnings);
+show('increment', $m->increment('l'), $m, $warnings);
+
+phpser_raw_set('unsigned', MEMC_TEST_IS_PHPSER, phpser_serialize(array('forged')));
+phpser_raw_set('signed_short', MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('forged'), 'too-short'));
+phpser_raw_set('ser', MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized());
+phpser_raw_set('str', MEMC_TEST_IS_STRING, 'forged');
+PhpserGadget::$events = array();
+
+show('get unsigned', $m->get('unsigned'), $m, $warnings);
+show('get signed with short key', $m->get('signed_short'), $m, $warnings);
+show('get serialized', $m->get('ser'), $m, $warnings);
+show('get string', $m->get('str'), $m, $warnings);
+
+restore_error_handler();
+echo "gadget events: ", json_encode(PhpserGadget::$events), "\n";
+var_dump(Memcached::RES_PAYLOAD_FAILURE);
+?>
+--EXPECT--
+set string: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"]
+set array: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"]
+set bool: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"]
+set long: true rc=0 []
+get long: 5 rc=0 []
+increment: 6 rc=0 []
+get unsigned: false rc=19 ["phpser.key must be at least 32 bytes; keyed value rejected"]
+get signed with short key: false rc=19 ["phpser.key must be at least 32 bytes; keyed value rejected"]
+get serialized: false rc=19 ["could not unserialize value: phpser.key is set and the value is not signed phpser"]
+get string: false rc=19 ["could not unserialize value: phpser.key is set and the value is not signed phpser"]
+gadget events: []
+int(-1001)
diff --git a/tests/phpser_unkeyed.phpt b/tests/phpser_unkeyed.phpt
new file mode 100644
index 00000000..b3a15c7b
--- /dev/null
+++ b/tests/phpser_unkeyed.phpt
@@ -0,0 +1,71 @@
+--TEST--
+Memcached phpser serializer without phpser.key: INI selection, native scalars, foreign types unchanged
+--SKIPIF--
+
+--INI--
+memcached.serializer=phpser
+--FILE--
+ false));
+var_dump($m->getOption(Memcached::OPT_SERIALIZER) === Memcached::SERIALIZER_PHPSER);
+
+foreach (array('str' => 'hello', 'true' => true, 'long' => 7, 'dbl' => 0.5, 'arr' => array(1), 'obj' => (object) array('a' => 1)) as $key => $value) {
+ $m->set($key, $value);
+ echo $key, " type=", phpser_raw_type($key), "\n";
+}
+var_dump(phpser_raw_get('arr')[1] === phpser_serialize(array(1)));
+
+echo "-- append works on native strings\n";
+var_dump($m->append('str', ' world'), $m->get('str'));
+
+echo "-- foreign types still decode by their stored flags\n";
+phpser_raw_set('ser', MEMC_TEST_IS_SERIALIZED, serialize(array('php' => 1)));
+var_dump($m->get('ser'));
+
+echo "-- a corrupt phpser item reads as false\n";
+phpser_raw_set('bad', MEMC_TEST_IS_PHPSER, 'not a phpser frame');
+var_dump($m->get('bad'), $m->getResultCode());
+
+echo "-- a signed item has a trailing tag that plain phpser rejects\n";
+phpser_raw_set('signed', MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('x'), PHPSER_TEST_KEY));
+var_dump($m->get('signed'));
+
+$m->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP);
+var_dump($m->get('arr'));
+?>
+--EXPECTF--
+bool(true)
+str type=0
+true type=3
+long type=1
+dbl type=2
+arr type=8
+obj type=8
+bool(true)
+-- append works on native strings
+bool(true)
+string(11) "hello world"
+-- foreign types still decode by their stored flags
+array(1) {
+ ["php"]=>
+ int(1)
+}
+-- a corrupt phpser item reads as false
+
+Warning: Memcached::get(): could not unserialize value with phpser in %s on line %d
+bool(false)
+int(19)
+-- a signed item has a trailing tag that plain phpser rejects
+
+Warning: Memcached::get(): could not unserialize value with phpser in %s on line %d
+bool(false)
+array(1) {
+ [0]=>
+ int(1)
+}
diff --git a/tests/types_phpser.phpt b/tests/types_phpser.phpt
new file mode 100644
index 00000000..1db18387
--- /dev/null
+++ b/tests/types_phpser.phpt
@@ -0,0 +1,24 @@
+--TEST--
+Memcached store & fetch type and value correctness using phpser serializer
+--SKIPIF--
+
+--FILE--
+
+--EXPECT--
+TEST DONE
+TEST DONE
diff --git a/tests/types_phpser_keyed.phpt b/tests/types_phpser_keyed.phpt
new file mode 100644
index 00000000..e097c7df
--- /dev/null
+++ b/tests/types_phpser_keyed.phpt
@@ -0,0 +1,26 @@
+--TEST--
+Memcached store & fetch type and value correctness using phpser serializer, phpser.key set
+--SKIPIF--
+
+--INI--
+phpser.key=memcached-phpser-test-key-0123456789
+--FILE--
+
+--EXPECT--
+TEST DONE
+TEST DONE