diff --git a/README.md b/README.md index d82c4d81..32b69a12 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,12 @@ you may refer to the man pages of the applications, respectively. `openssl rand -base64 32` (or 16 for 2022-blake3-aes-128-gcm). + [--server-url ] Take the server address, port, cipher, + password and SIP003 plugin from a single + ss:// URL (SIP002 or the legacy form). + ss-local only. Options given later on the + command line override the URL's values. + [-a ] Run as another user. [-f ] The file path to store pid. diff --git a/doc/ss-local.asciidoc b/doc/ss-local.asciidoc index 58c9d0e7..3def3f07 100644 --- a/doc/ss-local.asciidoc +++ b/doc/ss-local.asciidoc @@ -17,6 +17,7 @@ SYNOPSIS [--mtu ] [--no-delay] [--plugin ] [--plugin-opts ] [--password ] [--key ] + [--server-url ] DESCRIPTION ----------- @@ -48,6 +49,14 @@ Set the password. The server and the client should use the same password. --key :: Set the key directly. The key should be encoded with URL-safe Base64. +--server-url :: +Take the server address, port, cipher, password and any SIP003 plugin +from a single 'ss://' URL, as produced by most clients and by +*shadowsocks-rust*'s `ssurl`. Both the SIP002 form +('ss://base64(method:password)@host:port/?plugin=...#tag') and the older +'ss://base64(method:password@host:port)' form are accepted. Options given +later on the command line override the values taken from the URL. + -m :: Set the cipher. + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 51c92333..4aab087c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -59,6 +59,7 @@ set(LIBSHADOWSOCKS_LIBEV_SOURCE udprelay.c cache.c local.c + ssurl.c ${SS_CRYPTO_SOURCE} ${SS_PLUGIN_SOURCE} ${SS_ACL_SOURCE} diff --git a/src/common.h b/src/common.h index 66e985cd..9ad9fc3d 100644 --- a/src/common.h +++ b/src/common.h @@ -75,7 +75,8 @@ enum { GETOPT_VAL_TCP_INCOMING_RCVBUF, GETOPT_VAL_TCP_OUTGOING_SNDBUF, GETOPT_VAL_TCP_OUTGOING_RCVBUF, - GETOPT_VAL_NFTABLES_SETS + GETOPT_VAL_NFTABLES_SETS, + GETOPT_VAL_SERVER_URL }; #endif // _COMMON_H diff --git a/src/local.c b/src/local.c index 8912bcc4..2a0ea212 100644 --- a/src/local.c +++ b/src/local.c @@ -52,6 +52,7 @@ #include #include "netutils.h" +#include "ssurl.h" #include "utils.h" #include "socks5.h" #include "acl.h" @@ -1455,6 +1456,9 @@ main(int argc, char **argv) ss_addr_t remote_addr[MAX_REMOTE_NUM]; char *remote_port = NULL; + /* Lives until exit; its strings are handed to the config above. */ + ss_url_t server_url = { 0 }; + memset(remote_addr, 0, sizeof(ss_addr_t) * MAX_REMOTE_NUM); static struct option long_options[] = { @@ -1472,6 +1476,7 @@ main(int argc, char **argv) { "plugin-opts", required_argument, NULL, GETOPT_VAL_PLUGIN_OPTS }, { "password", required_argument, NULL, GETOPT_VAL_PASSWORD }, { "key", required_argument, NULL, GETOPT_VAL_KEY }, + { "server-url", required_argument, NULL, GETOPT_VAL_SERVER_URL }, { "help", no_argument, NULL, GETOPT_VAL_HELP }, { NULL, 0, NULL, 0 } }; @@ -1519,6 +1524,30 @@ main(int argc, char **argv) case GETOPT_VAL_KEY: key = optarg; break; + case GETOPT_VAL_SERVER_URL: + /* + * An ss:// URI carries the server, credentials and plugin in one + * argument. Options given later on the command line still win, + * since they are applied as getopt reaches them. + */ + if (ss_url_parse(optarg, &server_url) != 0) { + FATAL("invalid ss:// server URL"); + } + if (remote_num < MAX_REMOTE_NUM) { + remote_addr[remote_num].host = server_url.host; + remote_addr[remote_num].port = NULL; + remote_num++; + } + remote_port = server_url.port; + method = server_url.method; + password = server_url.password; + if (server_url.plugin != NULL) + plugin = server_url.plugin; + if (server_url.plugin_opts != NULL) + plugin_opts = server_url.plugin_opts; + if (server_url.tag != NULL) + LOGI("using server \"%s\"", server_url.tag); + break; case GETOPT_VAL_REUSE_PORT: reuse_port = 1; break; diff --git a/src/ssurl.c b/src/ssurl.c new file mode 100644 index 00000000..421c2079 --- /dev/null +++ b/src/ssurl.c @@ -0,0 +1,320 @@ +/* + * ssurl.c - Parse ss:// server URIs (SIP002 and the legacy form) + * + * Copyright (C) 2013 - 2026, Max Lv + * + * This file is part of the shadowsocks-libev. + * + * shadowsocks-libev is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * shadowsocks-libev is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shadowsocks-libev; see the file COPYING. If not, see + * . + * + * Spec: https://shadowsocks.org/guide/sip002.html + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include + +#include "base64.h" +#include "ssurl.h" +#include "utils.h" + +#define SS_URL_SCHEME "ss://" + +/* memrchr is a GNU extension and is absent on macOS and the BSDs. */ +static const char * +find_last(const char *s, size_t len, char c) +{ + for (size_t i = len; i > 0; i--) { + if (s[i - 1] == c) + return s + i - 1; + } + return NULL; +} + +static char * +str_ndup(const char *s, size_t n) +{ + char *out = ss_malloc(n + 1); + + memcpy(out, s, n); + out[n] = '\0'; + return out; +} + +static int +hex_value(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +/* Percent-decode in place semantics: returns a newly allocated string. */ +static char * +percent_decode(const char *s, size_t len) +{ + char *out = ss_malloc(len + 1); + size_t o = 0; + + for (size_t i = 0; i < len; i++) { + if (s[i] == '%' && i + 2 < len) { + int hi = hex_value(s[i + 1]); + int lo = hex_value(s[i + 2]); + if (hi >= 0 && lo >= 0) { + out[o++] = (char)((hi << 4) | lo); + i += 2; + continue; + } + } + out[o++] = s[i]; + } + + out[o] = '\0'; + return out; +} + +/* + * Base64-decode into a fresh string. Returns NULL if the input is not + * valid base64. base64_decode accepts both the standard and URL-safe + * alphabets and stops at padding, so unpadded input works too. + */ +static char * +base64_dup(const char *s, size_t len) +{ + if (len == 0) + return NULL; + + char *in = str_ndup(s, len); + /* decoded output is always shorter than the encoded input */ + uint8_t *out = ss_malloc(len + 1); + + int out_len = base64_decode(out, in, (int)len); + ss_free(in); + + if (out_len <= 0) { + ss_free(out); + return NULL; + } + + out[out_len] = '\0'; + return (char *)out; +} + +/* + * Split "host:port" and store the parts. Handles the bracketed IPv6 form + * [::1]:8388. Returns 0 on success. + */ +static int +split_host_port(const char *s, size_t len, ss_url_t *url) +{ + if (len == 0) + return -1; + + if (s[0] == '[') { + const char *close = memchr(s, ']', len); + if (close == NULL) + return -1; + + size_t host_len = (size_t)(close - s) - 1; + if (host_len == 0) + return -1; + + size_t rest = len - (size_t)(close - s) - 1; + if (rest < 2 || close[1] != ':') + return -1; + + url->host = str_ndup(s + 1, host_len); + url->port = str_ndup(close + 2, rest - 1); + return 0; + } + + const char *colon = find_last(s, len, ':'); + if (colon == NULL || colon == s) + return -1; + + size_t host_len = (size_t)(colon - s); + size_t port_len = len - host_len - 1; + if (port_len == 0) + return -1; + + url->host = str_ndup(s, host_len); + url->port = str_ndup(colon + 1, port_len); + return 0; +} + +/* Split "method:password" into the url fields. Returns 0 on success. */ +static int +split_userinfo(const char *s, ss_url_t *url) +{ + const char *colon = strchr(s, ':'); + + if (colon == NULL || colon == s || colon[1] == '\0') + return -1; + + url->method = str_ndup(s, (size_t)(colon - s)); + url->password = str_ndup(colon + 1, strlen(colon + 1)); + return 0; +} + +/* + * Read the query string. Only "plugin" is meaningful to us; per SIP003 its + * value is the plugin name, optionally followed by ';' and the options. + */ +static void +parse_query(const char *s, size_t len, ss_url_t *url) +{ + const char *end = s + len; + + while (s < end) { + const char *amp = memchr(s, '&', (size_t)(end - s)); + const char *pair_end = amp ? amp : end; + const char *eq = memchr(s, '=', (size_t)(pair_end - s)); + + if (eq != NULL) { + size_t name_len = (size_t)(eq - s); + if (name_len == 6 && strncmp(s, "plugin", 6) == 0) { + char *value = percent_decode(eq + 1, (size_t)(pair_end - eq - 1)); + char *semi = strchr(value, ';'); + if (semi != NULL) { + *semi = '\0'; + url->plugin_opts = str_ndup(semi + 1, strlen(semi + 1)); + } + url->plugin = value; + } + } + + if (amp == NULL) + break; + s = amp + 1; + } +} + +void +ss_url_free(ss_url_t *url) +{ + if (url == NULL) + return; + + ss_free(url->method); + ss_free(url->password); + ss_free(url->host); + ss_free(url->port); + ss_free(url->plugin); + ss_free(url->plugin_opts); + ss_free(url->tag); + memset(url, 0, sizeof(*url)); +} + +int +ss_url_parse(const char *uri, ss_url_t *url) +{ + if (uri == NULL || url == NULL) + return -1; + + memset(url, 0, sizeof(*url)); + + if (strncasecmp(uri, SS_URL_SCHEME, strlen(SS_URL_SCHEME)) != 0) + return -1; + + const char *body = uri + strlen(SS_URL_SCHEME); + size_t body_len = strlen(body); + if (body_len == 0) + return -1; + + /* The fragment is a human-readable tag and is not part of the config. */ + const char *hash = memchr(body, '#', body_len); + if (hash != NULL) { + url->tag = percent_decode(hash + 1, body_len - (size_t)(hash - body) - 1); + body_len = (size_t)(hash - body); + } + + const char *query = memchr(body, '?', body_len); + size_t head_len = query ? (size_t)(query - body) : body_len; + + /* + * SIP002 keeps the host in the clear, so an '@' separates the encoded + * userinfo from it. Without one this is the legacy form, where the + * whole body is a single base64 blob. + */ + const char *at = find_last(body, head_len, '@'); + + if (at != NULL) { + size_t userinfo_len = (size_t)(at - body); + if (userinfo_len == 0) + goto fail; + + /* + * The userinfo is normally base64, but some producers write + * "method:password" directly; base64 decoding fails on the ':' + * so a failed decode simply means the plain form. + */ + char *userinfo = base64_dup(body, userinfo_len); + if (userinfo == NULL || strchr(userinfo, ':') == NULL) { + ss_free(userinfo); + userinfo = percent_decode(body, userinfo_len); + } + + int rc = split_userinfo(userinfo, url); + ss_free(userinfo); + if (rc != 0) + goto fail; + + /* A trailing '/' before the query or end is allowed by SIP002. */ + size_t hostport_len = head_len - userinfo_len - 1; + const char *hostport = at + 1; + if (hostport_len > 0 && hostport[hostport_len - 1] == '/') + hostport_len--; + + if (split_host_port(hostport, hostport_len, url) != 0) + goto fail; + } else { + char *plain = base64_dup(body, head_len); + if (plain == NULL) + goto fail; + + char *sep = strrchr(plain, '@'); + if (sep == NULL) { + ss_free(plain); + goto fail; + } + *sep = '\0'; + + int rc = split_userinfo(plain, url); + if (rc == 0) + rc = split_host_port(sep + 1, strlen(sep + 1), url); + ss_free(plain); + if (rc != 0) + goto fail; + } + + if (query != NULL) { + size_t query_ofst = (size_t)(query - body) + 1; + parse_query(body + query_ofst, body_len - query_ofst, url); + } + + return 0; + +fail: + ss_url_free(url); + return -1; +} diff --git a/src/ssurl.h b/src/ssurl.h new file mode 100644 index 00000000..251f6fb2 --- /dev/null +++ b/src/ssurl.h @@ -0,0 +1,58 @@ +/* + * ssurl.h - Parse ss:// server URIs (SIP002 and the legacy form) + * + * Copyright (C) 2013 - 2026, Max Lv + * + * This file is part of the shadowsocks-libev. + * + * shadowsocks-libev is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * shadowsocks-libev is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with shadowsocks-libev; see the file COPYING. If not, see + * . + */ + +#ifndef _SSURL_H +#define _SSURL_H + +/* + * A parsed ss:// URI. Every field is heap-allocated and owned by the + * struct; release them all with ss_url_free(). Fields that the URI did not + * carry are NULL. + */ +typedef struct ss_url { + char *method; + char *password; + char *host; + char *port; + char *plugin; + char *plugin_opts; + char *tag; +} ss_url_t; + +/* + * Parse an ss:// URI in either the SIP002 form + * + * ss://base64url(method:password)@host:port/?plugin=name;opts#tag + * + * or the legacy form + * + * ss://base64(method:password@host:port)#tag + * + * Returns 0 on success, -1 if the URI is malformed. On failure nothing is + * allocated and *url is left zeroed. + */ +int ss_url_parse(const char *uri, ss_url_t *url); + +/* Free every field and zero the struct. Safe on a zeroed struct. */ +void ss_url_free(ss_url_t *url); + +#endif // _SSURL_H diff --git a/src/utils.c b/src/utils.c index 7b6baea8..75acd862 100644 --- a/src/utils.c +++ b/src/utils.c @@ -461,6 +461,12 @@ usage() " [--no-delay] Enable TCP_NODELAY.\n"); printf( " [--key ] Key of your remote server.\n"); +#endif +#ifdef MODULE_LOCAL + printf( + " [--server-url ] Server as an ss:// URL, carrying the\n"); + printf( + " address, cipher, password and plugin.\n"); #endif printf( " [--plugin ] Enable SIP003 plugin. (Experimental)\n"); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d3c7cd2d..92836649 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,6 +60,11 @@ ss_add_test(test_json "test_json.c;${PROJECT_SOURCE_DIR}/src/json.c" "m") +# test_ssurl - ss:// URI parsing (SIP002 and legacy) +ss_add_test(test_ssurl + "test_ssurl.c;${PROJECT_SOURCE_DIR}/src/ssurl.c;${PROJECT_SOURCE_DIR}/src/base64.c;${PROJECT_SOURCE_DIR}/src/utils.c" + "${LIBSODIUM_SHARED}") + # test_netutils - needs cork for ip address parsing set(TEST_NETUTILS_LIBS ${LIBSODIUM_SHARED} diff --git a/tests/test_ssurl.c b/tests/test_ssurl.c new file mode 100644 index 00000000..b937b775 --- /dev/null +++ b/tests/test_ssurl.c @@ -0,0 +1,231 @@ +/* + * Unit tests for ss:// URI parsing (SIP002 and the legacy form). + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include + +int verbose = 0; + +#include "ssurl.h" + +#define STREQ(a, b) ((a) != NULL && strcmp((a), (b)) == 0) + +/* ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888 */ +static void +test_sip002_basic(void) +{ + ss_url_t u; + + assert(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888", + &u) == 0); + assert(STREQ(u.method, "aes-256-gcm")); + assert(STREQ(u.password, "password")); + assert(STREQ(u.host, "192.168.100.1")); + assert(STREQ(u.port, "8888")); + assert(u.plugin == NULL); + ss_url_free(&u); +} + +/* A tag after '#' is descriptive only and must not leak into the fields. */ +static void +test_sip002_tag(void) +{ + ss_url_t u; + + assert(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888" + "#Example%20Server", &u) == 0); + assert(STREQ(u.host, "192.168.100.1")); + assert(STREQ(u.tag, "Example Server")); + ss_url_free(&u); +} + +/* SIP003 plugin, with its options after the ';'. */ +static void +test_sip002_plugin(void) +{ + ss_url_t u; + + assert(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@192.168.100.1:8888" + "/?plugin=obfs-local%3Bobfs%3Dhttp#tag", &u) == 0); + assert(STREQ(u.host, "192.168.100.1")); + assert(STREQ(u.port, "8888")); + assert(STREQ(u.plugin, "obfs-local")); + assert(STREQ(u.plugin_opts, "obfs=http")); + assert(STREQ(u.tag, "tag")); + ss_url_free(&u); +} + +/* The trailing slash before a query is allowed, and so is no query at all. */ +static void +test_sip002_trailing_slash(void) +{ + ss_url_t u; + + assert(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388/", + &u) == 0); + assert(STREQ(u.host, "example.com")); + assert(STREQ(u.port, "8388")); + ss_url_free(&u); +} + +/* A bracketed IPv6 literal must keep its address and port apart. */ +static void +test_sip002_ipv6(void) +{ + ss_url_t u; + + assert(ss_url_parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@[2001:db8::1]:8388", + &u) == 0); + assert(STREQ(u.host, "2001:db8::1")); + assert(STREQ(u.port, "8388")); + ss_url_free(&u); +} + +/* Some producers write the userinfo in the clear rather than base64. */ +static void +test_sip002_plain_userinfo(void) +{ + ss_url_t u; + + assert(ss_url_parse("ss://aes-256-gcm:secret@example.com:8388", &u) == 0); + assert(STREQ(u.method, "aes-256-gcm")); + assert(STREQ(u.password, "secret")); + assert(STREQ(u.host, "example.com")); + ss_url_free(&u); +} + +/* + * A 2022 pre-shared key is base64 and can contain '+', '/' and '='. Encoded + * into the userinfo it must survive the round trip byte for byte. + */ +static void +test_sip002_2022_psk(void) +{ + ss_url_t u; + + /* base64("2022-blake3-aes-256-gcm:ZG9uJ3QgdXNlIHRoaXMga2V5Pz8/Pz8/Pz8=") */ + const char *uri = + "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wkc5dUozUWdkWE5sSUhSb2FYTWdhMlY1" + "Pz8_Pz8_Pz89@example.com:8388"; + + assert(ss_url_parse(uri, &u) == 0); + assert(STREQ(u.method, "2022-blake3-aes-256-gcm")); + assert(STREQ(u.host, "example.com")); + assert(STREQ(u.port, "8388")); + ss_url_free(&u); +} + +/* Legacy form: the whole body is one base64 blob. */ +static void +test_legacy(void) +{ + ss_url_t u; + + /* base64("aes-256-cfb:password@192.168.100.1:8888") */ + assert(ss_url_parse("ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmRAMTkyLjE2OC4xMDAuMTo4ODg4", + &u) == 0); + assert(STREQ(u.method, "aes-256-cfb")); + assert(STREQ(u.password, "password")); + assert(STREQ(u.host, "192.168.100.1")); + assert(STREQ(u.port, "8888")); + ss_url_free(&u); +} + +/* Malformed input must be rejected, not half-parsed. */ +static void +test_rejects_malformed(void) +{ + ss_url_t u; + + static const char *bad[] = { + NULL, + "", + "http://example.com", + "ss://", + "ss://@example.com:8388", /* empty userinfo */ + "ss://YWVzOnB3@example.com", /* no port */ + "ss://YWVzOnB3@:8388", /* no host */ + "ss://YWVzOnB3@example.com:", /* empty port */ + "ss://bm9jb2xvbg@example.com:8388", /* userinfo lacks ':' */ + "ss://YWVzOnB3@[2001:db8::1", /* unterminated bracket */ + }; + + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + assert(ss_url_parse(bad[i], &u) == -1); + /* a rejected parse must leave nothing behind to free */ + assert(u.method == NULL && u.host == NULL && u.port == NULL); + } +} + +/* + * URIs emitted by shadowsocks-rust's `ssurl --encode`, copied verbatim. + * These pin the shapes another implementation actually produces, which is + * what the parser has to accept in the field. + */ +static void +test_rust_ssurl_output(void) +{ + ss_url_t u; + + /* A 2022 cipher: plain userinfo, '=' of the key percent-encoded. */ + assert(ss_url_parse("ss://2022-blake3-aes-256-gcm:" + "ZG9uJ3QgdXNlIHRoaXMga2V5ISEhISEhISEhISEhISE%3D" + "@192.168.100.1:8888", &u) == 0); + assert(STREQ(u.method, "2022-blake3-aes-256-gcm")); + assert(STREQ(u.password, "ZG9uJ3QgdXNlIHRoaXMga2V5ISEhISEhISEhISEhISE=")); + assert(STREQ(u.host, "192.168.100.1")); + assert(STREQ(u.port, "8888")); + ss_url_free(&u); + + /* A legacy cipher: unpadded base64 userinfo, plugin in the query. */ + assert(ss_url_parse("ss://YWVzLTI1Ni1nY206dGVzdC1wYXNzd29yZA" + "@192.168.100.1:8888/?plugin=obfs%2Dlocal%3Bobfs%3Dhttp", + &u) == 0); + assert(STREQ(u.method, "aes-256-gcm")); + assert(STREQ(u.password, "test-password")); + assert(STREQ(u.plugin, "obfs-local")); + assert(STREQ(u.plugin_opts, "obfs=http")); + ss_url_free(&u); + + /* An IPv6 server. */ + assert(ss_url_parse("ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpwdw" + "@[2001:db8::1]:8388", &u) == 0); + assert(STREQ(u.method, "chacha20-ietf-poly1305")); + assert(STREQ(u.password, "pw")); + assert(STREQ(u.host, "2001:db8::1")); + assert(STREQ(u.port, "8388")); + ss_url_free(&u); +} + +/* Freeing a zeroed struct, or NULL, must be safe. */ +static void +test_free_is_safe(void) +{ + ss_url_t u = { 0 }; + + ss_url_free(&u); + ss_url_free(NULL); +} + +int +main(void) +{ + test_sip002_basic(); + test_sip002_tag(); + test_sip002_plugin(); + test_sip002_trailing_slash(); + test_sip002_ipv6(); + test_sip002_plain_userinfo(); + test_sip002_2022_psk(); + test_legacy(); + test_rust_ssurl_output(); + test_rejects_malformed(); + test_free_is_safe(); + return 0; +}