diff options
author | Hans Nilsson <hans@erlang.org> | 2020-06-16 11:46:42 +0200 |
---|---|---|
committer | Hans Nilsson <hans@erlang.org> | 2020-06-16 11:46:42 +0200 |
commit | f363f1149a2ca59760584dca998f60ff21a9c0ec (patch) | |
tree | df9651805e02fed6956e220888a012e5a61d4e4c | |
parent | aa8131beb68d1c59f43385b51a98ddf2cf716301 (diff) | |
parent | f381a47a26da339d9bb2e08df8bf65460671d640 (diff) | |
download | erlang-f363f1149a2ca59760584dca998f60ff21a9c0ec.tar.gz |
Merge branch 'hans/ssh/cuddle_tests' into maint
* hans/ssh/cuddle_tests:
ssh: Test {tsflg,[{one_more,true}]}
ssh: Check user_dir_fun option
ssh: Test quiet-mode option
ssh: Testcase for encode/decode of pty_opts
ssh: ct:pal -> ct:log
ssh: Two new test cases for ssh:shell
ssh: Added missing test case for keyboard-interactive
-rw-r--r-- | lib/ssh/src/ssh_message.erl | 2 | ||||
-rw-r--r-- | lib/ssh/test/ssh_basic_SUITE.erl | 91 | ||||
-rw-r--r-- | lib/ssh/test/ssh_connection_SUITE.erl | 28 | ||||
-rw-r--r-- | lib/ssh/test/ssh_options_SUITE.erl | 175 | ||||
-rw-r--r-- | lib/ssh/test/ssh_test_lib.erl | 57 | ||||
-rw-r--r-- | lib/ssh/test/ssh_test_lib.hrl | 2 | ||||
-rw-r--r-- | lib/ssh/test/ssh_to_openssh_SUITE.erl | 4 |
7 files changed, 333 insertions, 26 deletions
diff --git a/lib/ssh/src/ssh_message.erl b/lib/ssh/src/ssh_message.erl index 804775bd75..12de0f83c2 100644 --- a/lib/ssh/src/ssh_message.erl +++ b/lib/ssh/src/ssh_message.erl @@ -690,6 +690,8 @@ bin_foldl(Fun, Acc0, Bin0) -> %%%---------------------------------------------------------------- decode_keyboard_interactive_prompts(<<>>, Acc) -> lists:reverse(Acc); +decode_keyboard_interactive_prompts(<<0>>, Acc) -> + lists:reverse(Acc); decode_keyboard_interactive_prompts(<<?DEC_BIN(Prompt,__0), ?BYTE(Bool), Bin/binary>>, Acc) -> decode_keyboard_interactive_prompts(Bin, [{Prompt, erl_boolean(Bool)} | Acc]). diff --git a/lib/ssh/test/ssh_basic_SUITE.erl b/lib/ssh/test/ssh_basic_SUITE.erl index d1713ae608..c13c563dee 100644 --- a/lib/ssh/test/ssh_basic_SUITE.erl +++ b/lib/ssh/test/ssh_basic_SUITE.erl @@ -90,20 +90,12 @@ groups() -> exec_with_io_out, exec_with_io_in, cli, idle_time_client, idle_time_server, openssh_zlib_basic_test, - misc_ssh_options, inet_option, inet6_option - - ,shell, - shell_no_unicode, - shell_unicode_string, - close - + misc_ssh_options, inet_option, inet6_option, + shell, shell_socket, shell_ssh_conn, shell_no_unicode, shell_unicode_string, + close ]} ]. - - - - %%-------------------------------------------------------------------- init_per_suite(Config) -> ?CHECK_CRYPTO(begin @@ -430,7 +422,8 @@ shell(Config) when is_list(Config) -> IO = ssh_test_lib:start_io_server(), Shell = ssh_test_lib:start_shell(Port, IO, [{user_dir,UserDir}]), receive - {'EXIT', _, _} -> + {'EXIT', _, _} = Exit -> + ct:log("~p:~p ~p", [?MODULE,?LINE,Exit]), ct:fail(no_ssh_connection); ErlShellStart -> ct:log("Erlang shell start: ~p~n", [ErlShellStart]), @@ -440,6 +433,76 @@ shell(Config) when is_list(Config) -> end. %%-------------------------------------------------------------------- +%%% Test that ssh:shell/2 works when attaching to a open TCP-connection +shell_socket(Config) when is_list(Config) -> + process_flag(trap_exit, true), + SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), + UserDir = proplists:get_value(priv_dir, Config), + + {_Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},{user_dir, UserDir}, + {failfun, fun ssh_test_lib:failfun/2}]), + ct:sleep(500), + + %% First test with active mode: + {ok,ActiveSock} = gen_tcp:connect(ssh_test_lib:mangle_connect_address(Host), + Port, + [{active,true}]), + {error,not_passive_mode} = ssh:shell(ActiveSock), + ct:log("~p:~p active tcp socket failed ok", [?MODULE,?LINE]), + gen_tcp:close(ActiveSock), + + %% Secondly, test with an UDP socket: + {ok,BadSock} = gen_udp:open(0), + {error,not_tcp_socket} = ssh:shell(BadSock), + ct:log("~p:~p udp socket failed ok", [?MODULE,?LINE]), + gen_udp:close(BadSock), + + %% And finaly test with passive mode (which should work): + IO = ssh_test_lib:start_io_server(), + {ok,Sock} = gen_tcp:connect(Host, Port, [{active,false}]), + Shell = ssh_test_lib:start_shell(Sock, IO, [{user_dir,UserDir}]), + gen_tcp:controlling_process(Sock, Shell), + Shell ! start, + + receive + {'EXIT', _, _} = Exit -> + ct:log("~p:~p ~p", [?MODULE,?LINE,Exit]), + ct:fail(no_ssh_connection); + ErlShellStart -> + ct:log("Erlang shell start: ~p~n", [ErlShellStart]), + do_shell(IO, Shell) + after + 30000 -> ct:fail("timeout ~p:~p",[?MODULE,?LINE]) + end. + +%%-------------------------------------------------------------------- +%%% Test that ssh:shell/2 works when attaching to a open SSH-connection +shell_ssh_conn(Config) when is_list(Config) -> + process_flag(trap_exit, true), + SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), + UserDir = proplists:get_value(priv_dir, Config), + + {_Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},{user_dir, UserDir}, + {failfun, fun ssh_test_lib:failfun/2}]), + ct:sleep(500), + + IO = ssh_test_lib:start_io_server(), + {ok,C} = ssh:connect(Host, Port, [{silently_accept_hosts, true}, + {user_dir, UserDir}, + {user_interaction, false}]), + Shell = ssh_test_lib:start_shell(C, IO, undefined), + receive + {'EXIT', _, _} = Exit -> + ct:log("~p:~p ~p", [?MODULE,?LINE,Exit]), + ct:fail(no_ssh_connection); + ErlShellStart -> + ct:log("Erlang shell start: ~p~n", [ErlShellStart]), + do_shell(IO, Shell) + after + 30000 -> ct:fail("timeout ~p:~p",[?MODULE,?LINE]) + end. + +%%-------------------------------------------------------------------- cli(Config) when is_list(Config) -> process_flag(trap_exit, true), SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), @@ -1251,6 +1314,7 @@ setopts_getopts(Config) -> {failfun, fun ssh_test_lib:failfun/2}]), ConnectionRef = ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {quiet_mode, true}, % Just to use quiet_mode once {user_dir, UserDir}, {user, "vego"}, {password, "morot"}, @@ -1357,7 +1421,8 @@ do_shell(IO, Shell) -> %%-------------------------------------------------------------------- wait_for_erlang_first_line(Config) -> receive - {'EXIT', _, _} -> + {'EXIT', _, _} = Exit -> + ct:log("~p:~p ~p", [?MODULE,?LINE,Exit]), {fail,no_ssh_connection}; <<"Eshell ",_/binary>> = _ErlShellStart -> ct:log("Erlang shell start: ~p~n", [_ErlShellStart]), diff --git a/lib/ssh/test/ssh_connection_SUITE.erl b/lib/ssh/test/ssh_connection_SUITE.erl index c4621723f6..5faa2808fb 100644 --- a/lib/ssh/test/ssh_connection_SUITE.erl +++ b/lib/ssh/test/ssh_connection_SUITE.erl @@ -63,6 +63,7 @@ all() -> start_exec_direct_fun1_read_write_advanced, start_shell_sock_exec_fun, start_shell_sock_daemon_exec, + encode_decode_pty_opts, connect_sock_not_tcp, daemon_sock_not_tcp, gracefull_invalid_version, @@ -343,6 +344,33 @@ send_after_exit(Config) when is_list(Config) -> end. %%-------------------------------------------------------------------- +encode_decode_pty_opts(_Config) -> + Tags = + [vintr, vquit, verase, vkill, veof, veol, veol2, vstart, vstop, vsusp, vdsusp, + vreprint, vwerase, vlnext, vflush, vswtch, vstatus, vdiscard, ignpar, parmrk, + inpck, istrip, inlcr, igncr, icrnl, iuclc, ixon, ixany, ixoff, imaxbel, isig, + icanon, xcase, echo, echoe, echok, echonl, noflsh, tostop, iexten, echoctl, + echoke, pendin, opost, olcuc, onlcr, ocrnl, onocr, onlret, cs7, cs8, parenb, + parodd, tty_op_ispeed, tty_op_ospeed], + Opts = + lists:zip(Tags, + lists:seq(1, length(Tags))), + + case ssh_connection:encode_pty_opts(Opts) of + Bin when is_binary(Bin) -> + case ssh_connection:decode_pty_opts(Bin) of + Opts -> + ok; + Other -> + ct:log("Expected ~p~nGot ~p~nBin = ~p",[Opts,Other,Bin]), + ct:fail("Not the same",[]) + end; + Other -> + ct:log("encode -> ~p",[Other]), + ct:fail("Encode failed",[]) + end. + +%%-------------------------------------------------------------------- ptty_alloc_default() -> [{doc, "Test sending PTTY alloc message with only defaults."}]. diff --git a/lib/ssh/test/ssh_options_SUITE.erl b/lib/ssh/test/ssh_options_SUITE.erl index 06c5e19456..7c222c9b06 100644 --- a/lib/ssh/test/ssh_options_SUITE.erl +++ b/lib/ssh/test/ssh_options_SUITE.erl @@ -50,6 +50,7 @@ server_pwdfun_option/1, server_pwdfun_4_option/1, server_keyboard_interactive/1, + server_keyboard_interactive_extra_msg/1, ssh_connect_arg4_timeout/1, ssh_connect_negtimeout_parallel/1, ssh_connect_negtimeout_sequential/1, @@ -62,7 +63,8 @@ system_dir_option/1, unexpectedfun_option_client/1, unexpectedfun_option_server/1, - user_dir_option/1, + user_dir_option/1, + user_dir_fun_option/1, connectfun_disconnectfun_server/1, hostkey_fingerprint_check/1, hostkey_fingerprint_check_md5/1, @@ -102,6 +104,10 @@ all() -> server_pwdfun_option, server_pwdfun_4_option, server_keyboard_interactive, + server_keyboard_interactive_extra_msg, + auth_method_kb_interactive_data_tuple, + auth_method_kb_interactive_data_fun3, + auth_method_kb_interactive_data_fun4, {group, dir_options}, ssh_connect_timeout, ssh_connect_arg4_timeout, @@ -144,6 +150,7 @@ groups() -> max_sessions_sftp_start_channel_sequential ]}, {dir_options, [], [user_dir_option, + user_dir_fun_option, system_dir_option]} ]. @@ -385,7 +392,7 @@ server_pwdfun_4_option(Config) -> %%-------------------------------------------------------------------- server_keyboard_interactive(Config) -> UserDir = proplists:get_value(user_dir, Config), - SysDir = proplists:get_value(data_dir, Config), + SysDir = proplists:get_value(data_dir, Config), %% Test that the state works Parent = self(), PWDFUN = fun("foo",P="bar",_,S) -> Parent!{P,S},true; @@ -440,7 +447,116 @@ server_keyboard_interactive(Config) -> end, [{"incorrect",undefined}, {"Bad again",1}, {"bar",2}]). - + +%%-------------------------------------------------------------------- +server_keyboard_interactive_extra_msg(Config) -> + UserDir = proplists:get_value(user_dir, Config), + SysDir = proplists:get_value(data_dir, Config), + {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SysDir}, + {user_dir, UserDir}, + {auth_methods,"keyboard-interactive"}, + {tstflg, [{one_empty,true}]}, + {user_passwords, [{"foo","bar"}]} + ]), + + ConnectionRef = + ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {user, "foo"}, + {password, "bar"}, + {user_dir, UserDir}]), + ssh:close(ConnectionRef), + ssh:stop_daemon(Pid). + +%%-------------------------------------------------------------------- +auth_method_kb_interactive_data_tuple(Config) -> + T = {"abc1", "def1", "ghi1: ", true}, + amkid(Config, T, T). + +auth_method_kb_interactive_data_fun3(Config) -> + T = {"abc2", "def2", "ghi2: ", true}, + amkid(Config, T, + fun(_Peer, _User, _Service) -> T end + ). + +auth_method_kb_interactive_data_fun4(Config) -> + T = {"abc3", "def3", "ghi3: ", true}, + amkid(Config, T, + fun(_Peer, _User, _Service, _State) -> T end + ). + +amkid(Config, {ExpectName,ExpectInstr,ExpectPrompts,ExpectEcho}, OptVal) -> + UserDir = proplists:get_value(user_dir, Config), + SysDir = proplists:get_value(data_dir, Config), + %% Test that the state works + Parent = self(), + PWDFUN = fun("foo",P="bar",_,S) -> Parent!{P,S},true; + (_,P,_,S=undefined) -> Parent!{P,S},{false,1}; + (_,P,_,S) -> Parent!{P,S}, {false,S+1} + end, + {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SysDir}, + {user_dir, UserDir}, + {auth_methods,"keyboard-interactive"}, + {pwdfun,PWDFUN}, + {auth_method_kb_interactive_data,OptVal} + ]), + + KIFFUN = fun(Name, Instr, PromptInfos) -> + K={k,self()}, + Answer = + case get(K) of + undefined -> + put(K,1), + ["incorrect"]; + 2 -> + put(K,3), + ["bar"]; + S-> + put(K,S+1), + ["Bad again"] + end, + ct:log("keyboard_interact_fun:~n" + " Name = ~p~n" + " Instruction = ~p~n" + " Prompts = ~p~n" + "~nAnswer:~n ~p~n", + [Name, Instr, PromptInfos, Answer]), + case {binary_to_list(Name), + binary_to_list(Instr), + [{binary_to_list(PI),Echo} || {PI,Echo} <- PromptInfos] + } of + {ExpectName, ExpectInstr, [{ExpectPrompts,ExpectEcho}]} -> + ct:log("Match!", []), + Answer; + _ -> + ct:log("Not match!~n" + " ExpectName = ~p~n" + " ExpectInstruction = ~p~n" + " ExpectPrompts = ~p~n", + [ExpectName, ExpectInstr, [{ExpectPrompts,ExpectEcho}]]), + ct:fail("no_match") + end + end, + ssh_dbg:start(), ssh_dbg:on(authentication), %% Test dbg code + ConnectionRef2 = + ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {user, "foo"}, + {keyboard_interact_fun, KIFFUN}, + {user_dir, UserDir}]), + ssh_dbg:stop(), + ssh:close(ConnectionRef2), + ssh:stop_daemon(Pid), + + lists:foreach(fun(Expect) -> + receive + Expect -> ok; + Other -> ct:fail("Expect: ~p~nReceived ~p",[Expect,Other]) + after + 2000 -> ct:fail("Timeout expecting ~p",[Expect]) + end + end, [{"incorrect",undefined}, + {"Bad again",1}, + {"bar",2}]). + %%-------------------------------------------------------------------- system_dir_option(Config) -> DirUnread = proplists:get_value(unreadable_dir,Config), @@ -462,7 +578,7 @@ system_dir_option(Config) -> ct:fail("Didn't detect that option is a plain file", []) end. - +%%-------------------------------------------------------------------- user_dir_option(Config) -> DirUnread = proplists:get_value(unreadable_dir,Config), FileRead = proplists:get_value(readable_file,Config), @@ -484,6 +600,44 @@ user_dir_option(Config) -> end. %%-------------------------------------------------------------------- +user_dir_fun_option(Config) -> + DataDir = proplists:get_value(data_dir, Config), + PrivDir = proplists:get_value(priv_dir, Config), + SysDir = filename:join(PrivDir,"system"), + ssh_test_lib:setup_all_host_keys(DataDir, SysDir), + UserDir = filename:join(PrivDir,"user"), + ssh_test_lib:setup_all_user_keys(DataDir, UserDir), + + Parent = self(), + Ref = make_ref(), + {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SysDir}, + {user_dir_fun, fun(User) -> + ct:log("user_dir_fun called ~p",[User]), + Parent ! {user,Ref,User}, + UserDir + end}, + {failfun, fun ssh_test_lib:failfun/2}]), + _ConnectionRef = + ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {user, "foo"}, + {user_dir, UserDir}, + {auth_methods,"publickey"}, + {user_interaction, false}]), + receive + {user,Ref,"foo"} -> + ssh:stop_daemon(Pid), + ok; + {user,Ref,What} -> + ssh:stop_daemon(Pid), + ct:log("Got ~p",[What]), + {fail, bad_userid} + after 2000 -> + ssh:stop_daemon(Pid), + {fail,timeout_in_receive} + end. + + +%%-------------------------------------------------------------------- %%% validate client that uses the 'ssh_msg_debug_fun' option ssh_msg_debug_fun_option_client(Config) -> UserDir = proplists:get_value(user_dir, Config), @@ -1035,7 +1189,7 @@ id_string_random_client(Config) -> receive {id,Server,Id="SSH-2.0-Erlang"++_} -> ct:fail("Unexpected id: ~s.",[Id]); - {id,Server,Rnd="SSH-2.0-"++_} -> + {id,Server,Rnd="SSH-2.0-"++ID} when 4=<length(ID),length(ID)=<7 -> %% Add 2 for CRLF ct:log("Got correct ~s",[Rnd]); {id,Server,Id} -> ct:fail("Unexpected id: ~s.",[Id]) @@ -1064,14 +1218,21 @@ id_string_own_string_server_trail_space(Config) -> %%-------------------------------------------------------------------- id_string_random_server(Config) -> - {_Server, Host, Port} = ssh_test_lib:std_daemon(Config, [{id_string,random}]), + %% Check undocumented format of id_string. First a bad variant: + {error,{eoptions,_}} = ssh:daemon(0, [{id_string,{random,8,6}}]), + %% And then a correct one: + {_Server, Host, Port} = ssh_test_lib:std_daemon(Config, [{id_string,{random,6,8}}]), {ok,S1}=ssh_test_lib:gen_tcp_connect(Host,Port,[{active,false},{packet,line}]), {ok,"SSH-2.0-"++Rnd} = gen_tcp:recv(S1, 0, 2000), case Rnd of "Erlang"++_ -> ct:log("Id=~p",[Rnd]), {fail,got_default_id}; "Olle\r\n" -> {fail,got_previous_tests_value}; - _ -> ct:log("Got ~s.",[Rnd]) + _ when 8=<length(Rnd),length(Rnd)=<10 -> %% Add 2 for CRLF + ct:log("Got correct ~s",[Rnd]); + _ -> + ct:log("Got wrong sized ~s.",[Rnd]), + {fail,got_wrong_size} end. %%-------------------------------------------------------------------- diff --git a/lib/ssh/test/ssh_test_lib.erl b/lib/ssh/test/ssh_test_lib.erl index 2645c956cf..8a0c209eb2 100644 --- a/lib/ssh/test/ssh_test_lib.erl +++ b/lib/ssh/test/ssh_test_lib.erl @@ -184,11 +184,44 @@ start_shell(Port, IOServer) -> start_shell(Port, IOServer, ExtraOptions) -> spawn_link( fun() -> - Host = hostname(), + ct:log("~p:~p:~p ssh_test_lib:start_shell(~p, ~p, ~p)", + [?MODULE,?LINE,self(), Port, IOServer, ExtraOptions]), Options = [{user_interaction, false}, {silently_accept_hosts,true} | ExtraOptions], - group_leader(IOServer, self()), - ssh:shell(Host, Port, Options) + try + group_leader(IOServer, self()), + case Port of + 22 -> + Host = hostname(), + ct:log("Port==22 Call ssh:shell(~p, ~p)", + [Host, Options]), + ssh:shell(Host, Options); + _ when is_integer(Port) -> + Host = hostname(), + ct:log("is_integer(Port) Call ssh:shell(~p, ~p, ~p)", + [Host, Port, Options]), + ssh:shell(Host, Port, Options); + Socket when is_port(Socket) -> + receive + start -> ok + end, + ct:log("is_port(Socket) Call ssh:shell(~p, ~p)", + [Socket, Options]), + ssh:shell(Socket, Options); + ConnRef when is_pid(ConnRef) -> + ct:log("is_pid(ConnRef) Call ssh:shell(~p)", + [ConnRef]), + ssh:shell(ConnRef) % Options were given in ssh:connect + end + of + R -> + ct:log("~p:~p ssh_test_lib:start_shell(~p, ~p, ~p) -> ~p", + [?MODULE,?LINE,Port, IOServer, ExtraOptions, R]) + catch + C:E:S -> + ct:log("Exception ~p:~p~n~p", [C,E,S]), + ct:fail("Exception",[]) + end end). @@ -991,6 +1024,24 @@ setup_all_host_keys(DataDir, SysDir) -> end end, [], ssh_transport:supported_algorithms(public_key)). + +setup_all_user_keys(DataDir, UserDir) -> + lists:foldl(fun(Alg, OkAlgs) -> + try + ok = ssh_test_lib:setup_user_key(Alg, DataDir, UserDir) + of + ok -> [Alg|OkAlgs] + catch + error:{badmatch, {error,enoent}} -> + OkAlgs; + C:E:S -> + ct:log("Exception in ~p:~p for alg ~p: ~p:~p~n~p", + [?MODULE,?FUNCTION_NAME,Alg,C,E,S]), + OkAlgs + end + end, [], ssh_transport:supported_algorithms(public_key)). + + setup_user_key(SshAlg, DataDir, UserDir) -> file:make_dir(UserDir), %% Copy private user key to user's dir diff --git a/lib/ssh/test/ssh_test_lib.hrl b/lib/ssh/test/ssh_test_lib.hrl index 098ee79044..0e36793955 100644 --- a/lib/ssh/test/ssh_test_lib.hrl +++ b/lib/ssh/test/ssh_test_lib.hrl @@ -29,7 +29,7 @@ case FunctionCall of Pattern when Guard -> Bind; _ when N>0 -> - ct:pal("Must sleep ~p ms at ~p:~p",[Timeout,?MODULE,?LINE]), + ct:log("Must sleep ~p ms at ~p:~p",[Timeout,?MODULE,?LINE]), timer:sleep(Timeout), F1(N-1, F1); Other -> diff --git a/lib/ssh/test/ssh_to_openssh_SUITE.erl b/lib/ssh/test/ssh_to_openssh_SUITE.erl index 426efbb5e3..14abd503d8 100644 --- a/lib/ssh/test/ssh_to_openssh_SUITE.erl +++ b/lib/ssh/test/ssh_to_openssh_SUITE.erl @@ -168,7 +168,7 @@ exec_with_io_in_sshc(Config) when is_list(Config) -> " -x" % Disable X forwarding ], ExecStr), - ct:pal("Cmd = ~p~n",[Cmd]), + ct:log("Cmd = ~p~n",[Cmd]), case os:cmd(Cmd) of "% {ok,howdy}" -> ok; "{ok,howdy}% " -> ok; % Could happen if the client sends the piped @@ -201,7 +201,7 @@ exec_direct_with_io_in_sshc(Config) when is_list(Config) -> " -x" % Disable X forwarding ], "'? '"), - ct:pal("Cmd = ~p~n",[Cmd]), + ct:log("Cmd = ~p~n",[Cmd]), case os:cmd(Cmd) of "? {ciao,\"oaic\"}" -> ok; "'? '{ciao,\"oaic\"}" -> ok; % WSL |