diff --git a/net/http/inc/THttpServer.h b/net/http/inc/THttpServer.h index c612773362be6..74f69239db425 100644 --- a/net/http/inc/THttpServer.h +++ b/net/http/inc/THttpServer.h @@ -94,6 +94,10 @@ class THttpServer : public TNamed { void SetReadOnly(Bool_t readonly = kTRUE); + Bool_t IsAllowPostObject() const; + + void SetAllowPostObject(Bool_t allow_post_obj); + Bool_t IsWSOnly() const; void SetWSOnly(Bool_t on = kTRUE); diff --git a/net/http/inc/TRootSniffer.h b/net/http/inc/TRootSniffer.h index 4351e66ffc9ed..558473ec4ce84 100644 --- a/net/http/inc/TRootSniffer.h +++ b/net/http/inc/TRootSniffer.h @@ -120,6 +120,7 @@ class TRootSniffer : public TNamed { protected: TString fObjectsPath; /// fTopFolder; ///SetReadOnly(readonly); } +//////////////////////////////////////////////////////////////////////////////// +/// Returns true if server accept object content in POST reequests + +Bool_t THttpServer::IsAllowPostObject() const +{ + return fSniffer ? fSniffer->IsAllowPostObject() : kFALSE; +} + +//////////////////////////////////////////////////////////////////////////////// +/// Set flag to allow receive and desereilize objects in POST requests +/// +/// When object methods are executed via exe.json request, +/// one can supply object as binary/json/xml in the body of POST request +/// To allow creation of such object, one need to enable this flag +/// Use of exe.json only possible in not-readonly mode +/// +/// CAUTION! This is sensitive functionality and therefore should be +/// used only when server not exposed to publicaly-accessed netowork. + +void THttpServer::SetAllowPostObject(Bool_t allow_post_obj) +{ + if (fSniffer) + fSniffer->SetAllowPostObject(allow_post_obj); +} + //////////////////////////////////////////////////////////////////////////////// /// returns true if only websockets are handled by the server /// diff --git a/net/http/src/TRootSniffer.cxx b/net/http/src/TRootSniffer.cxx index b95b63ca9a55b..374711482d83d 100644 --- a/net/http/src/TRootSniffer.cxx +++ b/net/http/src/TRootSniffer.cxx @@ -33,6 +33,8 @@ #include #include #include +#include + const char *item_prop_kind = "_kind"; const char *item_prop_more = "_more"; @@ -1213,9 +1215,41 @@ Bool_t TRootSniffer::ExecuteCmd(const std::string &path, const std::string &opti return kTRUE; } - TString svalue = DecodeUrlOptionValue(argvalue, kTRUE); argname = TString("%") + argname + TString("%"); - method.ReplaceAll(argname, svalue); + auto p = method.Index(argname); + if (p == kNPOS) + continue; + + method.Remove(p, argname.Length()); + + if ((p > 0) && (p < method.Length()) && (method.Length() > 1) && (method[p-1] == '"') && (method[p] == '"')) { + // command definition has quotes around argument + // one can insert value from URL removing quotes + method.Insert(p, DecodeUrlOptionValue(argvalue, kTRUE)); + continue; + } + + // extract argument without removing quotes + TString svalue = DecodeUrlOptionValue(argvalue, kFALSE); + + if ((svalue.Length() > 1) && (svalue[0] == '"') && (svalue[svalue.Length() - 1] == '"')) { + // if value itself has quotes, all special symbols already escaped and one can insert it as is + method.Insert(p, svalue); + continue; + } + + Bool_t is_numeric = kTRUE; + // expect decimal, hex or float values here, E/e also belong to hex + for(Size_t i = 0; is_numeric && (i < svalue.Length()); ++i) + is_numeric = std::isxdigit(svalue[i]) || std::strchr(".+-", svalue[i]); + + // always quote content which not numeric + if (!is_numeric) + svalue = "\"" + svalue + "\""; + else if (svalue.IsNull()) + svalue = "0"; + + method.Insert(p, svalue); } } @@ -1289,8 +1323,8 @@ Bool_t TRootSniffer::ProduceXml(const std::string &/* path */, const std::string TString TRootSniffer::DecodeUrlOptionValue(const char *value, Bool_t remove_quotes) { - if (!value || (strlen(value) == 0)) - return TString(); + if (!value || !*value) + return ""; TString res = value; @@ -1303,13 +1337,40 @@ TString TRootSniffer::DecodeUrlOptionValue(const char *value, Bool_t remove_quot res.ReplaceAll("%5D", "]"); res.ReplaceAll("%3D", "="); - if (remove_quotes && (res.Length() > 1) && ((res[0] == '\'') || (res[0] == '\"')) && - (res[0] == res[res.Length() - 1])) { + Char_t quote = 0; + + if ((res.Length() > 1) && ((res[0] == '\'') || (res[0] == '\"')) && (res[0] == res[res.Length() - 1])) + quote = res[0]; + + // first remove quotes + if (quote) { res.Remove(res.Length() - 1); res.Remove(0, 1); } - return res; + // we expect normal content here, no special symbols, no unescaped quotes + TString clean; + for (Ssiz_t i = 0; i < res.Length(); ++i) { + char c = res[i]; + if (c == '"' || c == '\\') { + // escape quotes and slahes + clean.Append('\\'); + clean.Append(c); + } else if (!std::iscntrl(c)) + // ignore all special symbols + clean.Append(c); + } + + if (quote && !remove_quotes) { + // return string with quotes - when desired + res = ""; + res.Append(quote); + res.Append(clean); + res.Append(quote); + return res; + } + + return clean; } //////////////////////////////////////////////////////////////////////////////// diff --git a/net/httpsniff/CMakeLists.txt b/net/httpsniff/CMakeLists.txt index 55b47fcd9e0c5..7541839a8b50e 100644 --- a/net/httpsniff/CMakeLists.txt +++ b/net/httpsniff/CMakeLists.txt @@ -1,12 +1,12 @@ -# Copyright (C) 1995-2019, Rene Brun and Fons Rademakers. +# Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. # All rights reserved. # # For the licensing terms see $ROOTSYS/LICENSE. # For the list of contributors see $ROOTSYS/README/CREDITS. ############################################################################ -# CMakeLists.txt file for building ROOT net/http package -# @author Pere Mato, CERN +# CMakeLists.txt file for building ROOT net/httpsniff package +# @author Sergey Linev, GSI ############################################################################ ROOT_STANDARD_LIBRARY_PACKAGE(RHTTPSniff @@ -24,3 +24,5 @@ ROOT_STANDARD_LIBRARY_PACKAGE(RHTTPSniff Tree XMLIO ) + +ROOT_ADD_TEST_SUBDIRECTORY(test) diff --git a/net/httpsniff/src/TRootSnifferFull.cxx b/net/httpsniff/src/TRootSnifferFull.cxx index c9f265fb2f43e..580ba3d854889 100644 --- a/net/httpsniff/src/TRootSnifferFull.cxx +++ b/net/httpsniff/src/TRootSnifferFull.cxx @@ -416,7 +416,7 @@ Bool_t TRootSnifferFull::ProduceImage(Int_t kind, const std::string &path, const if (gDebug > 1) Info("TRootSniffer", "Crate IMAGE from object %s", obj->GetName()); - Int_t width(300), height(200); + Int_t width = 300, height = 200; TString drawopt; if (!options.empty()) { @@ -429,9 +429,7 @@ Bool_t TRootSnifferFull::ProduceImage(Int_t kind, const std::string &path, const Int_t h = url.GetIntValueFromOptions("h"); if (h > 10) height = h; - const char *opt = url.GetValueFromOptions("opt"); - if (opt) - drawopt = opt; + drawopt = DecodeUrlOptionValue(url.GetValueFromOptions("opt"), kTRUE); } Bool_t isbatch = gROOT->IsBatch(); @@ -627,47 +625,46 @@ Bool_t TRootSnifferFull::ProduceExe(const std::string &path, const std::string & return debug != nullptr; const char *rest_url = pos + strlen(method_name) + 7; if (*rest_url == '&') ++rest_url; - call_args.Form("\"%s\"", rest_url); + call_args.Append("\""); + call_args.Append(DecodeUrlOptionValue(rest_url, kTRUE)); + call_args.Append("\""); break; } TString sval; const char *val = url.GetValueFromOptions(arg->GetName()); - if (val) { - sval = DecodeUrlOptionValue(val, kFALSE); - val = sval.Data(); - } + if (val) + sval = DecodeUrlOptionValue(val, kTRUE); + + Bool_t sanitize_numeric = kFALSE; - if ((val != nullptr) && (strcmp(val, "_this_") == 0)) { + if (sval == "_this_") { // special case - object itself is used as argument sval.Form("(%s*)0x%zx", obj_cl->GetName(), (size_t)obj_ptr); - val = sval.Data(); - } else if ((val != nullptr) && (fCurrentArg != nullptr) && (fCurrentArg->GetPostData() != nullptr)) { + } else if ((fCurrentArg != nullptr) && (fCurrentArg->GetPostData() != nullptr)) { // process several arguments which are specific for post requests - if (strcmp(val, "_post_object_xml_") == 0) { + if (fAllowPostObject && (sval == "_post_object_xml_")) { // post data has extra 0 at the end and can be used as null-terminated string post_obj = TBufferXML::ConvertFromXML((const char *)fCurrentArg->GetPostData()); - if (!post_obj) { + if (!post_obj) sval = "0"; - } else { + else { sval.Form("(%s*)0x%zx", post_obj->ClassName(), (size_t)post_obj); if (url.HasOption("_destroy_post_")) garbage.Add(post_obj); } - val = sval.Data(); - } else if (strcmp(val, "_post_object_json_") == 0) { + } else if (fAllowPostObject && (sval == "_post_object_json_")) { // post data has extra 0 at the end and can be used as null-terminated string post_obj = TBufferJSON::ConvertFromJSON((const char *)fCurrentArg->GetPostData()); - if (!post_obj) { + if (!post_obj) sval = "0"; - } else { + else { sval.Form("(%s*)0x%zx", post_obj->ClassName(), (size_t)post_obj); if (url.HasOption("_destroy_post_")) garbage.Add(post_obj); } - val = sval.Data(); - } else if ((strcmp(val, "_post_object_") == 0) && url.HasOption("_post_class_")) { - TString clname = url.GetValueFromOptions("_post_class_"); + } else if (fAllowPostObject && (sval == "_post_object_") && url.HasOption("_post_class_")) { + TString clname = DecodeUrlOptionValue(url.GetValueFromOptions("_post_class_"), kTRUE); TClass *arg_cl = gROOT->GetClass(clname, kTRUE, kTRUE); if ((arg_cl != nullptr) && (arg_cl->GetBaseClassOffset(TObject::Class()) == 0) && (post_obj == nullptr)) { post_obj = (TObject *)arg_cl->New(); @@ -684,39 +681,61 @@ Bool_t TRootSnifferFull::ProduceExe(const std::string &path, const std::string & garbage.Add(post_obj); } } - sval.Form("(%s*)0x%zx", clname.Data(), (size_t)post_obj); - val = sval.Data(); - } else if (strcmp(val, "_post_data_") == 0) { + if (!post_obj) + sval = "0"; + else + sval.Form("(%s*)0x%zx", clname.Data(), (size_t)post_obj); + } else if (sval == "_post_data_") sval.Form("(void*)0x%zx", (size_t)fCurrentArg->GetPostData()); - val = sval.Data(); - } else if (strcmp(val, "_post_length_") == 0) { + else if (sval == "_post_length_") sval.Form("%ld", (long)fCurrentArg->GetPostDataLength()); - val = sval.Data(); - } - } + else + sanitize_numeric = kTRUE; + } else + sanitize_numeric = kTRUE; - if (!val) - val = arg->GetDefault(); + if (sval.IsNull() && arg->GetDefault()) + sval = arg->GetDefault(); if (debug) - debug->append(TString::Format(" Argument:%s Type:%s Value:%s \n", arg->GetName(), arg->GetFullTypeName(), - val ? val : "") - .Data()); - if (!val) - return debug != nullptr; + debug->append( + TString::Format(" Argument:%s Type:%s Value:%s \n", arg->GetName(), arg->GetFullTypeName(), sval.Data()) + .Data()); if (call_args.Length() > 0) call_args += ", "; - if ((strcmp(arg->GetFullTypeName(), "const char*") == 0) || (strcmp(arg->GetFullTypeName(), "Option_t*") == 0)) { - int len = strlen(val); - if ((strlen(val) < 2) || (*val != '\"') || (val[len - 1] != '\"')) - call_args.Append(TString::Format("\"%s\"", val)); - else - call_args.Append(val); + Bool_t isstr = (strcmp(arg->GetFullTypeName(), "const char*") == 0) || + (strcmp(arg->GetFullTypeName(), "Option_t*") == 0) || + (strcmp(arg->GetFullTypeName(), "string") == 0); + + if (isstr) { + // check that quotes provided for the string argument + // all special characters were escaped before + if (sval.IsNull()) + sval = "\"\""; + else { + if (sval[0] != '"') + sval.Prepend("\""); + if (sval[sval.Length() - 1] != '"') + sval.Append("\""); + } } else { - call_args.Append(val); + // for numeric types keep only numeric and alphabetic characters + // exclude others - especially remove all escape characters + if (sanitize_numeric) { + TString sanitized; + for(Size_t i = 0; i < sval.Length(); ++i) { + if (std::isalnum(sval[i]) || std::strchr(".:+-", sval[i])) + sanitized.Append(sval[i]); + } + sval = sanitized; + } + if (sval.IsNull()) + sval = "0"; } + + call_args.Append(sval); } TMethodCall *call = nullptr; diff --git a/net/httpsniff/test/CMakeLists.txt b/net/httpsniff/test/CMakeLists.txt new file mode 100644 index 0000000000000..b23ad58177deb --- /dev/null +++ b/net/httpsniff/test/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. +# All rights reserved. +# +# For the licensing terms see $ROOTSYS/LICENSE. +# For the list of contributors see $ROOTSYS/README/CREDITS. + +############################################################################ +# CMakeLists.txt file for building ROOT net/http package +# @author Sergey Linev, GSI +############################################################################ + +ROOT_ADD_GTEST(testRootSniffer test_sniffer.cxx LIBRARIES RHTTPSniff) diff --git a/net/httpsniff/test/test_sniffer.cxx b/net/httpsniff/test/test_sniffer.cxx new file mode 100644 index 0000000000000..240d064caef6a --- /dev/null +++ b/net/httpsniff/test/test_sniffer.cxx @@ -0,0 +1,336 @@ +#include "gtest/gtest.h" + +#include + +#include "TNamed.h" +#include "TH1.h" +#include "TBufferJSON.h" +#include "THttpCallArg.h" +#include "TROOT.h" +#include "TRootSnifferFull.h" + +#include "ROOT/TestSupport.hxx" + + +// simple class to access protected method + +class TDecodeTest : public TRootSniffer { + public: + std::string Decode(const char *value, Bool_t remove_quotes = kTRUE) + { + TString res = DecodeUrlOptionValue(value, remove_quotes); + return res.Data(); + } +}; + +// check basic URL parameters decoding +TEST(TRootSniffer, decode_url_options) +{ + TDecodeTest test; + + EXPECT_EQ(test.Decode(""), ""); + + // single quote has to be escaped + EXPECT_EQ(test.Decode("\""), "\\\""); + + // single backalsh has to be escaped + EXPECT_EQ(test.Decode("\\"), "\\\\"); + + // remove quotes + EXPECT_EQ(test.Decode("\"\""), ""); + + // remove quotes and escape quotes + EXPECT_EQ(test.Decode("\"\"\""), "\\\""); + + // remove quotes and escape backslah + EXPECT_EQ(test.Decode("\"\\\""), "\\\\"); + + // remove quotes and remove special charsescape backslah + EXPECT_EQ(test.Decode("\"abc\njkl\t\""), "abcjkl"); + + // escape quotes in the middle + EXPECT_EQ(test.Decode("someFunc(\"someArg\");someArray[3];"), "someFunc(\\\"someArg\\\");someArray[3];"); + + // keep quotes + EXPECT_EQ(test.Decode("\"\"", kFALSE), "\"\""); + + // keep quotes and escape inside quotes + EXPECT_EQ(test.Decode("\"\"\"", kFALSE), "\"\\\"\""); + + // keep quotes and keep german letters - remove new line + EXPECT_EQ(test.Decode("\"Gänse\nfüßchen\"", kFALSE), "\"Gänsefüßchen\""); +} + +// check JSON representation for the objects +TEST(TRootSniffer, root_json) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj); + + std::string res; + sniffer.Produce("/obj", "root.json", "", res); + EXPECT_EQ(res, "{\n" + " \"_typename\" : \"TNamed\",\n" + " \"fUniqueID\" : 0,\n" + " \"fBits\" : 8,\n" + " \"fName\" : \"obj\",\n" + " \"fTitle\" : \"title\"\n" + "}"); +} + +// check XML representation for the objects +TEST(TRootSniffer, root_xml) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj); + + std::string res; + sniffer.Produce("/obj", "root.xml", "", res); + EXPECT_EQ(res, "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"); +} + +// check BINARY representation for the objects +TEST(TRootSniffer, root_bin) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj); + + std::string res; + sniffer.Produce("/obj", "root.bin", "", res); + // keep minimal margin for binary format change + EXPECT_NEAR(res.length(), 26, 4); +} + +// check root file creation for the objects +TEST(TRootSniffer, file_root) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj); + + std::string res; + sniffer.Produce("/obj", "file.root", "", res); + // TODO: anlyze why so big size for small object + EXPECT_NEAR(res.length(), 2097152, 10000); +} + +// check hierarchy request +TEST(TRootSniffer, item_json) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj); + + std::string res; + sniffer.Produce("/obj", "item.json", "", res); + + EXPECT_EQ(res, "{\n" + " \"_name\" : \"obj\",\n" + " \"_root_version\" : " + std::to_string(gROOT->GetVersionCode()) + ",\n" + " \"_kind\" : \"ROOT.TNamed\",\n" + " \"_title\" : \"title\"\n" + "}"); +} + +// simple method execution +TEST(TRootSniffer, exe_json) +{ + TNamed obj("obj","title"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj); + + std::string res0; + // by default methods execution is not allowed + sniffer.Produce("/obj", "exe.json", "method=GetTitle", res0); + EXPECT_EQ(res0, ""); + + // disable readonly to get method executed + sniffer.SetReadOnly(kFALSE); + + // only now one can execute method + std::string res1; + sniffer.Produce("/obj", "exe.json", "method=GetTitle", res1); + EXPECT_EQ(res1, "\"title\""); +} + + +// execute method with post data - lot of gymnastic around +TEST(TRootSniffer, exe_post_json) +{ + TH1I hist("hist", "title", 10, 0, 10); + hist.SetDirectory(nullptr); + hist.SetBinContent(5, 10); + + std::string json; + { + // only temporary to create json + TH1I hist2("hist", "title", 10, 0, 10); + hist.SetDirectory(nullptr); + hist2.SetBinContent(5, 20); + json = TBufferJSON::ToJSON(&hist2).Data(); + } + + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &hist); + + // disable readonly to execute method + sniffer.SetReadOnly(kFALSE); + // allow use of POST data to decode object from JSON + sniffer.SetAllowPostObject(kTRUE); + + THttpCallArg arg; + arg.SetPostData(std::move(json)); + sniffer.SetCurrentCallArg(&arg); + + // before execution content is 10 + EXPECT_EQ(hist.GetBinContent(5), 10); + + + std::string res; + sniffer.Produce("/hist", "exe.json", "method=Add&prototype='const TH1*,Double_t'&h1=_post_object_json_&_destroy_post_", res); + EXPECT_EQ(res, "1"); + + // and now most important - bin content has to change + EXPECT_EQ(hist.GetBinContent(5), 30); +} + +// changing object title +TEST(TRootSniffer, set_title) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + // disable readonly to get method executed + sniffer.SetReadOnly(kFALSE); + + sniffer.RegisterObject("/", &obj); + + std::string res; + + sniffer.Produce("/obj", "exe.json", "method=SetTitle&title=NewTitle", res); + EXPECT_EQ(res, "null"); + EXPECT_EQ(std::string("NewTitle"), obj.GetTitle()); + + res = ""; + sniffer.Produce("/obj", "exe.json", "method=SetTitle&title=\"QuotedTitle\"", res); + EXPECT_EQ(res, "null"); + EXPECT_EQ(std::string("QuotedTitle"), obj.GetTitle()); + + res = ""; + sniffer.Produce("/obj", "exe.json", "method=SetTitle&title=%22UrlStyleQuotedTitle%22", res); + EXPECT_EQ(res, "null"); + EXPECT_EQ(std::string("UrlStyleQuotedTitle"), obj.GetTitle()); + + res = ""; + sniffer.Produce("/obj", "exe.json", "method=SetTitle&title=Mail\"Formed\"Title", res); + EXPECT_EQ(res, "null"); + EXPECT_EQ(std::string("Mail\"Formed\"Title"), obj.GetTitle()); +} + +// testing command execution with different signatures +TEST(TRootSniffer, cmd_json) +{ + TNamed obj("obj", "title"); + + TRootSnifferFull sniffer; + sniffer.SetReadOnly(kFALSE); + + sniffer.RegisterObject("/", &obj); + sniffer.RegisterCommand("/Print1", "/obj/->Print(%arg1%)", ""); + sniffer.RegisterCommand("/Print2", "/obj/->Print(\"%arg1%\")", ""); + sniffer.RegisterCommand("/GetSize", "/obj/->Sizeof()", ""); + + std::string res; + // quotes are in URL + sniffer.Produce("/Print1", "cmd.json", "arg1=%22*%22", res); + EXPECT_EQ(res, "0"); + + res = ""; + // skipping quotes from URL - when they are necessary + // sniffer should have add them automatically + sniffer.Produce("/Print1", "cmd.json", "arg1=*", res); + EXPECT_EQ(res, "0"); + + res = ""; + // skipping quotes from URL - when they are necessary + // while value looks like number, sniffer will not quote it + // result of process line is not result is + sniffer.Produce("/Print1", "cmd.json", "arg1=0", res); + EXPECT_EQ(res, "0"); + + res = ""; + // quotes are in command definition + sniffer.Produce("/Print2", "cmd.json", "arg1=*", res); + EXPECT_EQ(res, "0"); + + res = ""; + // quotes are in command definition but we try to add our own + // sniffer will remove them + sniffer.Produce("/Print2", "cmd.json", "arg1=\"*\"", res); + EXPECT_EQ(res, "0"); + + res = ""; + // Execute command which returns some value + sniffer.Produce("/GetSize", "cmd.json", "", res); + // returns only strings sizes + EXPECT_EQ(res, "10"); +} + + + +// check JSON representation for the objects +TEST(TRootSniffer, multi_json) +{ + TNamed obj1("obj1", "title1"); + TNamed obj2("obj2", "title2"); + + TRootSnifferFull sniffer; + + sniffer.RegisterObject("/", &obj1); + sniffer.RegisterObject("/", &obj2); + + std::string items = "/obj1/root.json\n/obj2/root.json\n"; + + THttpCallArg arg; + arg.SetPostData(std::move(items)); + sniffer.SetCurrentCallArg(&arg); + + std::string res; + sniffer.Produce("", "multi.json", "number=2", res); + EXPECT_EQ(res, "[{\n" + " \"_typename\" : \"TNamed\",\n" + " \"fUniqueID\" : 0,\n" + " \"fBits\" : 8,\n" + " \"fName\" : \"obj1\",\n" + " \"fTitle\" : \"title1\"\n" + "}, {\n" + " \"_typename\" : \"TNamed\",\n" + " \"fUniqueID\" : 0,\n" + " \"fBits\" : 8,\n" + " \"fName\" : \"obj2\",\n" + " \"fTitle\" : \"title2\"\n" + "}]"); +}