Repository: redis/redis

Problem statement:
[BUG] command line redis-cli --scan incorrectly returns binary keys
**Describe the bug**

redis-cli --scan does not work correctly with binary keys:
- binary data is not returned to terminal
- scan operation yields no matches when MATCH pattern contains binary data

**To reproduce**
Set binary key
```
127.0.0.1:6379> set "cp\x00\x01\x15\xc5\x00\x01\x15\xc5b64068ae-3d87-4d9a-b928-847adf4e22bf" "\x00\x02"
OK
```

command line scan with binary pattern yields no results
```
~ $ redis-cli --scan --pattern "cp\x00*"
~ $
```

command line scan without binary pattern (does not return binary part of the key)
```
~ $ redis-cli --scan --pattern "cp*"
cp
~ $
```

**Expected behavior**

Expected to set key and retrieve in command line scan
```
127.0.0.1:6379> set "cp\x00\x01\x15\xc5\x00\x01\x15\xc5b64068ae-3d87-4d9a-b928-847adf4e22bf" "\x00\x02"
OK
```

command line scan by with binary pattern
```
~ $ redis-cli --scan --pattern "cp\x00*"
cp\x00\x01\x15\xc5\x00\x01\x15\xc5b64068ae-3d87-4d9a-b928-847adf4e22bf
~ $
```

command line scan without binary pattern 
```
~ $ redis-cli --scan --pattern "cp*"
cp\x00\x01\x15\xc5\x00\x01\x15\xc5b64068ae-3d87-4d9a-b928-847adf4e22bf
~ $
```

**Additional information**
running scan from the client works as expected:
```
127.0.0.1:6379> scan 0 match cp* count 10000
1) "0"
2) 1) "cp\x00\x01\x15\xc5\x00\x01\x15\xc5b64068ae-3d87-4d9a-b928-847adf4e22bf"
```



Hints:

Patch:
diff --git a/src/redis-cli.c b/src/redis-cli.c
index 3930a2d8e01..83672dec93c 100644
--- a/src/redis-cli.c
+++ b/src/redis-cli.c
@@ -227,7 +227,7 @@ static struct config {
     int scan_mode;
     int intrinsic_latency_mode;
     int intrinsic_latency_duration;
-    char *pattern;
+    sds pattern;
     char *rdb_filename;
     int bigkeys;
     int memkeys;
@@ -237,6 +237,7 @@ static struct config {
     char *auth;
     int askpass;
     char *user;
+    int quoted_input;   /* Force input args to be treated as quoted strings */
     int output; /* output mode, see OUTPUT_* defines */
     int push_output; /* Should we display spontaneous PUSH replies */
     sds mb_delim;
@@ -763,6 +764,23 @@ static void freeHintsCallback(void *ptr) {
  * Networking / parsing
  *--------------------------------------------------------------------------- */
 
+/* Unquote a null-terminated string and return it as a binary-safe sds. */
+static sds unquoteCString(char *str) {
+    int count;
+    sds *unquoted = sdssplitargs(str, &count);
+    sds res = NULL;
+
+    if (unquoted && count == 1) {
+        res = unquoted[0];
+        unquoted[0] = NULL;
+    }
+
+    if (unquoted)
+        sdsfreesplitres(unquoted, count);
+
+    return res;
+}
+
 /* Send AUTH command to the server */
 static int cliAuth(redisContext *ctx, char *user, char *auth) {
     redisReply *reply;
@@ -1533,6 +1551,8 @@ static int parseOptions(int argc, char **argv) {
             config.output = OUTPUT_RAW;
         } else if (!strcmp(argv[i],"--no-raw")) {
             config.output = OUTPUT_STANDARD;
+        } else if (!strcmp(argv[i],"--quoted-input")) {
+            config.quoted_input = 1;
         } else if (!strcmp(argv[i],"--csv")) {
             config.output = OUTPUT_CSV;
         } else if (!strcmp(argv[i],"--latency")) {
@@ -1557,7 +1577,15 @@ static int parseOptions(int argc, char **argv) {
         } else if (!strcmp(argv[i],"--scan")) {
             config.scan_mode = 1;
         } else if (!strcmp(argv[i],"--pattern") && !lastarg) {
-            config.pattern = argv[++i];
+            sdsfree(config.pattern);
+            config.pattern = sdsnew(argv[++i]);
+        } else if (!strcmp(argv[i],"--quoted-pattern") && !lastarg) {
+            sdsfree(config.pattern);
+            config.pattern = unquoteCString(argv[++i]);
+            if (!config.pattern) {
+                fprintf(stderr,"Invalid quoted string specified for --quoted-pattern.\n");
+                exit(1);
+            }
         } else if (!strcmp(argv[i],"--intrinsic-latency") && !lastarg) {
             config.intrinsic_latency_mode = 1;
             config.intrinsic_latency_duration = atoi(argv[++i]);
@@ -1841,6 +1869,7 @@ static void usage(void) {
 "  --raw              Use raw formatting for replies (default when STDOUT is\n"
 "                     not a tty).\n"
 "  --no-raw           Force formatted output even when STDOUT is not a tty.\n"
+"  --quoted-input     Force input to be handled as quoted strings.\n"
 "  --csv              Output in CSV format.\n"
 "  --show-pushes <yn> Whether to print RESP3 PUSH messages.  Enabled by default when\n"
 "                     STDOUT is a tty but can be overriden with --show-pushes no.\n"
@@ -1876,6 +1905,8 @@ static void usage(void) {
 "  --scan             List all keys using the SCAN command.\n"
 "  --pattern <pat>    Keys pattern when using the --scan, --bigkeys or --hotkeys\n"
 "                     options (default: *).\n"
+"  --quoted-pattern <pat> Same as --pattern, but the specified string can be\n"
+"                         quoted, in order to pass an otherwise non binary-safe string.\n"
 "  --intrinsic-latency <sec> Run a test to measure intrinsic system latency.\n"
 "                     The test will run for the specified amount of seconds.\n"
 "  --eval <file>      Send an EVAL command using the Lua script at <file>.\n"
@@ -1901,6 +1932,7 @@ static void usage(void) {
 "  redis-cli get mypasswd\n"
 "  redis-cli -r 100 lpush mylist x\n"
 "  redis-cli -r 100 -i 1 info | grep used_memory_human:\n"
+"  redis-cli --quoted-input set '\"null-\\x00-separated\"' value\n"
 "  redis-cli --eval myscript.lua key1 key2 , arg1 arg2 arg3\n"
 "  redis-cli --scan --pattern '*:12345*'\n"
 "\n"
@@ -1930,22 +1962,28 @@ static int confirmWithYes(char *msg, int ignore_force) {
     return (nread != 0 && !strcmp("yes", buf));
 }
 
-/* Turn the plain C strings into Sds strings */
-static char **convertToSds(int count, char** args) {
-    int j;
-    char **sds = zmalloc(sizeof(char*)*count);
-
-    for(j = 0; j < count; j++)
-        sds[j] = sdsnew(args[j]);
-
-    return sds;
-}
+/* Create an sds array from argv, either as-is or by dequoting every
+ * element. When quoted is non-zero, may return a NULL to indicate an
+ * invalid quoted string.
+ */
+static sds *getSdsArrayFromArgv(int argc, char **argv, int quoted) {
+    sds *res = sds_malloc(sizeof(sds) * argc);
+
+    for (int j = 0; j < argc; j++) {
+        if (quoted) {
+            sds unquoted = unquoteCString(argv[j]);
+            if (!unquoted) {
+                while (--j >= 0) sdsfree(res[j]);
+                sds_free(res);
+                return NULL;
+            }
+            res[j] = unquoted;
+        } else {
+            res[j] = sdsnew(argv[j]);
+        }
+    }
 
-static void freeConvertedSds(int count, char **sds) {
-    int j;
-    for (j = 0; j < count; j++)
-        sdsfree(sds[j]);
-    zfree(sds);
+    return res;
 }
 
 static int issueCommandRepeat(int argc, char **argv, long repeat) {
@@ -2178,17 +2216,19 @@ static void repl(void) {
 
 static int noninteractive(int argc, char **argv) {
     int retval = 0;
-
-    argv = convertToSds(argc, argv);
+    sds *sds_args = getSdsArrayFromArgv(argc, argv, config.quoted_input);
+    if (!sds_args) {
+        printf("Invalid quoted string\n");
+        return 1;
+    }
     if (config.stdinarg) {
-        argv = zrealloc(argv, (argc+1)*sizeof(char*));
-        argv[argc] = readArgFromStdin();
-        retval = issueCommand(argc+1, argv);
-        sdsfree(argv[argc]);
-    } else {
-        retval = issueCommand(argc, argv);
+        sds_args = sds_realloc(sds_args, (argc + 1) * sizeof(sds));
+        sds_args[argc] = readArgFromStdin();
+        argc++;
     }
-    freeConvertedSds(argc, argv);
+
+    retval = issueCommand(argc, sds_args);
+    sdsfreesplitres(sds_args, argc);
     return retval;
 }
 
@@ -7332,8 +7372,8 @@ static redisReply *sendScan(unsigned long long *it) {
     redisReply *reply;
 
     if (config.pattern)
-        reply = redisCommand(context,"SCAN %llu MATCH %s",
-            *it,config.pattern);
+        reply = redisCommand(context, "SCAN %llu MATCH %b",
+            *it, config.pattern, sdslen(config.pattern));
     else
         reply = redisCommand(context,"SCAN %llu",*it);
 
@@ -7945,23 +7985,16 @@ static void scanMode(void) {
     unsigned long long cur = 0;
 
     do {
-        if (config.pattern)
-            reply = redisCommand(context,"SCAN %llu MATCH %s",
-                cur,config.pattern);
-        else
-            reply = redisCommand(context,"SCAN %llu",cur);
-        if (reply == NULL) {
-            printf("I/O error\n");
-            exit(1);
-        } else if (reply->type == REDIS_REPLY_ERROR) {
-            printf("ERROR: %s\n", reply->str);
-            exit(1);
-        } else {
-            unsigned int j;
-
-            cur = strtoull(reply->element[0]->str,NULL,10);
-            for (j = 0; j < reply->element[1]->elements; j++)
+        reply = sendScan(&cur);
+        for (unsigned int j = 0; j < reply->element[1]->elements; j++) {
+            if (config.output == OUTPUT_STANDARD) {
+                sds out = sdscatrepr(sdsempty(), reply->element[1]->element[j]->str,
+                                     reply->element[1]->element[j]->len);
+                printf("%s\n", out);
+                sdsfree(out);
+            } else {
                 printf("%s\n", reply->element[1]->element[j]->str);
+            }
         }
         freeReplyObject(reply);
     } while(cur != 0);


Test patch:
diff --git a/tests/integration/redis-cli.tcl b/tests/integration/redis-cli.tcl
index d877542ed35..7e8b41fcabf 100644
--- a/tests/integration/redis-cli.tcl
+++ b/tests/integration/redis-cli.tcl
@@ -207,6 +207,28 @@ start_server {tags {"cli"}} {
         assert_equal "foo\nbar" [run_cli lrange list 0 -1]
     }
 
+    test_nontty_cli "Quoted input arguments" {
+        r set "\x00\x00" "value"
+        assert_equal "value" [run_cli --quoted-input get {"\x00\x00"}]
+    }
+
+    test_nontty_cli "No accidental unquoting of input arguments" {
+        run_cli --quoted-input set {"\x41\x41"} quoted-val
+        run_cli set {"\x41\x41"} unquoted-val
+
+        assert_equal "quoted-val" [r get AA]
+        assert_equal "unquoted-val" [r get {"\x41\x41"}]
+    }
+
+    test_nontty_cli "Invalid quoted input arguments" {
+        catch {run_cli --quoted-input set {"Unterminated}} err
+        assert_match {*exited abnormally*} $err
+
+        # A single arg that unquotes to two arguments is also not expected
+        catch {run_cli --quoted-input set {"arg1" "arg2"}} err
+        assert_match {*exited abnormally*} $err
+    }
+
     test_nontty_cli "Read last argument from pipe" {
         assert_equal "OK" [run_cli_with_input_pipe "echo foo" set key]
         assert_equal "foo\n" [r get key]
@@ -247,6 +269,20 @@ start_server {tags {"cli"}} {
         test_redis_cli_rdb_dump
     }
 
+    test "Scan mode" {
+        r flushdb
+        populate 1000 key: 1
+
+        # basic use
+        assert_equal 1000 [llength [split [run_cli --scan]]]
+
+        # pattern
+        assert_equal {key:2} [run_cli --scan --pattern "*:2"]
+
+        # pattern matching with a quoted string
+        assert_equal {key:2} [run_cli --scan --quoted-pattern {"*:\x32"}]
+    }
+
     test "Connecting as a replica" {
         set fd [open_cli "--replica"]
         wait_for_condition 500 500 {