diff --git a/configure.ac b/configure.ac index e4d7d2b0..5d75cf3d 100644 --- a/configure.ac +++ b/configure.ac @@ -22,6 +22,8 @@ RAK_ENABLE_DEBUG RAK_ENABLE_EXTRA_DEBUG RAK_ENABLE_WERROR +TORRENT_CHECK_ATOMIC + AC_ARG_ENABLE(execinfo, AS_HELP_STRING([--disable-execinfo], [disable libexecinfo [[default=enable]]]), @@ -68,7 +70,7 @@ AC_DEFINE(USER_AGENT, [std::string(PACKAGE "/" VERSION)], Http user agent) dnl Only update global build variables immediately before generating the output, dnl to avoid affecting the global build environment for other autoconf checks. -LIBS="$PTHREAD_LIBS $CURSES_LIB $CURSES_LIBS $ZLIB_LIBS $DEPENDENCIES_LIBS $LIBS" +LIBS="$ATOMIC_LIBS $PTHREAD_LIBS $CURSES_LIB $CURSES_LIBS $ZLIB_LIBS $DEPENDENCIES_LIBS $LIBS" CFLAGS="$CFLAGS $PTHREAD_CFLAGS $CURSES_CFLAGS $ZLIB_CFLAGS $DEPENDENCIES_CFLAGS" CXXFLAGS="$CXXFLAGS $PTHREAD_CFLAGS $CURSES_CFLAGS $ZLIB_CFLAGS $DEPENDENCIES_CFLAGS" diff --git a/doc/manual/paths.md b/doc/manual/paths.md new file mode 100644 index 00000000..0c4d88cb --- /dev/null +++ b/doc/manual/paths.md @@ -0,0 +1,60 @@ +# Resolving paths + +Commands that return a path have `.realpath` variants that resolve it to a +canonical one, with symlinks followed and any `.` or `..` components removed. + +The intent is to make paths safer to hand to an external script. +A script called through `execute` receives whatever path rtorrent gives it, and +many scripts do no sanity checking of their own, so resolving the path before it +leaves rtorrent removes a class of surprises: a download directory that is a +symlink into somewhere unexpected, or a torrent whose name walks upwards out of +the directory it is supposed to live in. + + # Instead of this + execute = ~/bin/on-finished, (d.base_path) + + # Pass the resolved path + execute = ~/bin/on-finished, (d.base_path.realpath.or_throw) + +## Available variants + +Each command below comes in an `.or_empty` and an `.or_throw` form. + +| Command | Resolves | +| --- | --- | +| `d.base_path.realpath.*` | `d.base_path` | +| `d.directory.realpath.*` | `d.directory` | +| `d.tied_to_file.realpath.*` | `d.tied_to_file` | +| `d.loaded_file.realpath.*` | `d.loaded_file` | +| `f.frozen_path.realpath.*` | `f.frozen_path` | +| `session.path.realpath.*` | `session.path` | +| `directory.default.realpath.*` | `directory.default` | + +A leading `~` is expanded first, exactly as it is for `execute`, so +`~/downloads` resolves the same way it would on the command line. + +## Paths that do not exist + +Resolving requires the path to name an existing file or directory, which is +often not the case for a download whose data has not been written yet. +The two forms differ only in what they do about it. + +`.or_empty` returns an empty string: + + print = (d.base_path.realpath.or_empty) # "" + +This keeps `d.multicall` usable over a view that mixes started and unstarted +downloads, since a single unresolvable path does not abort the whole call. +A script receiving one of these paths should still check that it is not empty +before acting on it. + +`.or_throw` raises an error instead: + + print = (d.base_path.realpath.or_throw) # Could not resolve path: '...' + +Prefer this one wherever an unresolvable path means the command should not run +at all, such as a single `execute` on `event.download.finished`. + +Note that a download only has a file list once it has been opened, so +`d.base_path` and `f.frozen_path` are empty at `event.download.inserted` time +and their `.realpath` variants resolve nothing. diff --git a/doc/manual/string_functions.md b/doc/manual/string_functions.md new file mode 100644 index 00000000..306bdd1b --- /dev/null +++ b/doc/manual/string_functions.md @@ -0,0 +1,139 @@ +# String functions + +The `string.*` commands manipulate and inspect text from within the +configuration file. +They are pure functions with no side effects, so they can be nested freely and +are safe to call over RPC. + +Every argument is converted to its string representation before use, which means +numbers can be passed where text is expected. +Commands that count characters, such as `string.length` and `string.substr`, count +utf-8 characters rather than bytes. + +## string.length + + # string.length = «text» + + string.length = "héllo" # 5 + +Returns the number of utf-8 characters in the text. + +## string.equals + + # string.equals = «text», «other»[, ...] + + string.equals = (d.name), "first.iso", "second.iso" + +Returns `1` if the first argument equals any of the following arguments, +otherwise `0`. + +## string.starts_with, string.ends_with + + # string.starts_with = «text», «prefix»[, ...] + # string.ends_with = «text», «tail»[, ...] + + string.starts_with = (t.url), "http://", "https://" + string.ends_with = (d.name), ".iso" + +Returns `1` if the text begins, or ends, with any of the given prefixes or +tails. + +## string.contains, string.contains_i + + # string.contains = «haystack», «needle»[, ...] + # string.contains_i = «haystack», «needle»[, ...] + + string.contains = (t.url), "retracker.local" + string.contains_i = (t.url), "RETRACKER.local" + +Returns `1` if the haystack contains any of the needles. +The `_i` variant compares case-insensitively, and only handles ascii. + +## string.substr + + # string.substr = «text»[, «position»[, «count»[, «default»]]] + + string.substr = "abcdef", 2, 3 # "cde" + string.substr = "abcdef", -2 # "ef" + string.substr = "abcdef", 10, 1, "?" # "?" + +Extracts a part of the text, starting at `position` and spanning `count` +characters. +The position defaults to the start of the text, and the count to the rest of it. +A negative position is relative to the end of the text. +If the position falls outside the text, the default value is returned instead, +which is the empty string unless given. + +## string.split + + # string.split = «text», «delimiter» + + string.split = "a.b.c", "." # {"a", "b", "c"} + string.split = "abc", "" # {"a", "b", "c"} + +Splits the text into a list, keeping empty fields. +An empty delimiter splits the text into its utf-8 characters. + +## string.join + + # string.join = «delimiter»[, «object»[, ...]] + + string.join = "-", (string.split, "a.b.c", ".") # "a-b-c" + +Concatenates the objects, inserting the delimiter between them. +Lists are flattened, so the result of `string.split` can be joined back +together. + +## string.lpad, string.rpad + + # string.lpad = «text», «length»[, «padding»] + # string.rpad = «text», «length»[, «padding»] + + string.lpad = 7, 3, 0 # "007" + string.rpad = "a", 3 # "a " + +Pads the text at the start, or the end, until it is `length` characters long. +The padding defaults to a single space and is repeated as needed. +Text that is already long enough is returned unchanged. + +## string.strip, string.lstrip, string.rstrip + + # string.strip = «text»[, «strippable»[, ...]] + # string.lstrip = «text»[, «head»[, ...]] + # string.rstrip = «text»[, «tail»[, ...]] + + string.strip = " padded " # "padded" + string.strip = "//path//", "/" # "path" + +Removes characters from both ends of the text, or from only the start or the +end. +The arguments after the text form a set of utf-8 characters to remove. +Without them, whitespace is removed. + +## string.map + + # string.map = «text», {«old», «new»}[, ...] + + string.map = (d.state), {0, "stopped"}, {1, "started"} + +Returns the replacement of the first pair whose `old` value equals the whole +text. +If no pair matches, the text is returned unchanged. + +## string.replace + + # string.replace = «text», {«old», «new»}[, ...] + + string.replace = "a-b-c", {"-", "+"} # "a+b+c" + +Replaces every occurrence of `old` with `new`. +The pairs are applied in order, so a later pair operates on the result of the +earlier ones. + +## Example: dropping unwanted trackers + +The following disables every tracker that points at `retracker.local` as soon as +a download is inserted. + + method.set_key = event.download.inserted, drop_retracker, \ + ((t.multicall, default, "branch=(string.contains,(t.url),retracker.local),((t.disable))")) diff --git a/doc/rtorrent.rc b/doc/rtorrent.rc index 08c35267..aa2e6fdf 100644 --- a/doc/rtorrent.rc +++ b/doc/rtorrent.rc @@ -71,11 +71,11 @@ # Port range to use for listening. # -#network.port_range.set = 6890-6999 +#network.listen.port.range.set = 6890-6999 # Start opening ports at a random position within the port range. # -#network.port_random.set = no +#network.listen.port.random.set = no # Set RPC type #network.rpc.use_xmlrpc.set = true diff --git a/doc/rtorrent.rc-example b/doc/rtorrent.rc-example index 9cfe8747..ed9aeea1 100644 --- a/doc/rtorrent.rc-example +++ b/doc/rtorrent.rc-example @@ -23,8 +23,8 @@ execute.throw = sh, -c, (cat,\ "\"",(cfg.watch),"/start\" ") # Listening port for incoming peer traffic (fixed; you can also randomize it) -network.port_range.set = 50000-50000 -network.port_random.set = no +network.listen.port.range.set = 50000-50000 +network.listen.port.random.set = no # Tracker-less torrent and UDP tracker support # (conservative settings for 'private' trackers, change for 'public') diff --git a/src/Makefile.am b/src/Makefile.am index d322db72..e53c2f96 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -194,6 +194,7 @@ libsub_root_a_SOURCES = \ command_throttle.cc \ command_tracker.cc \ command_scheduler.cc \ + command_string.cc \ command_ui.cc \ control.cc \ control.h \ diff --git a/src/command_download.cc b/src/command_download.cc index bdba3ff8..cb208307 100644 --- a/src/command_download.cc +++ b/src/command_download.cc @@ -655,6 +655,8 @@ initialize_command_download() { CMD2_DL("d.base_path.as_binary", [](auto* download, auto) { return retrieve_d_base_path(download).object_as_binary(); }); CMD2_DL("d.base_path.or_base64", [](auto* download, auto) { return retrieve_d_base_path(download).object_utf8_or_base64(); }); CMD2_DL("d.base_path.or_as_binary", [](auto* download, auto) { return retrieve_d_base_path(download).object_utf8_or_as_binary(); }); + CMD2_DL("d.base_path.realpath.or_empty", [](auto* download, auto) { return resolve_path(retrieve_d_base_path(download).str()); }); + CMD2_DL("d.base_path.realpath.or_throw", [](auto* download, auto) { return resolve_path_or_throw(retrieve_d_base_path(download).str()); }); CMD2_DL("d.base_filename", [](auto* download, auto) { return retrieve_d_base_filename(download).str(); }); CMD2_DL("d.base_filename.hex", [](auto* download, auto) { return retrieve_d_base_filename(download).object_hex(); }); CMD2_DL("d.base_filename.base64", [](auto* download, auto) { return retrieve_d_base_filename(download).object_base64(); }); @@ -771,6 +773,11 @@ initialize_command_download() { CMD2_DL_VAR_STRING_PUBLIC("d.tied_to_file", "rtorrent", "tied_to_file"); CMD2_DL_VAR_STRING("d.loaded_file", "rtorrent", "loaded_file"); + CMD2_DL("d.tied_to_file.realpath.or_empty", [](auto* download, auto) { return resolve_path(rpc::convert_to_string(download_get_variable(download, "rtorrent", "tied_to_file"))); }); + CMD2_DL("d.tied_to_file.realpath.or_throw", [](auto* download, auto) { return resolve_path_or_throw(rpc::convert_to_string(download_get_variable(download, "rtorrent", "tied_to_file"))); }); + CMD2_DL("d.loaded_file.realpath.or_empty", [](auto* download, auto) { return resolve_path(rpc::convert_to_string(download_get_variable(download, "rtorrent", "loaded_file"))); }); + CMD2_DL("d.loaded_file.realpath.or_throw", [](auto* download, auto) { return resolve_path_or_throw(rpc::convert_to_string(download_get_variable(download, "rtorrent", "loaded_file"))); }); + // The "state_changed" variable is required to be a valid unix time // value, it indicates the last time the torrent changed its state, // resume/pause. @@ -878,6 +885,8 @@ initialize_command_download() { CMD2_DL_VALUE_V ("d.tracker.send_scrape", [](auto download, uint64_t arg) { download->tracker_controller().scrape_request(arg); }); CMD2_DL ("d.directory", CMD2_ON_FL(root_dir)); + CMD2_DL ("d.directory.realpath.or_empty", [](auto* download, auto) { return resolve_path(download->file_list()->root_dir()); }); + CMD2_DL ("d.directory.realpath.or_throw", [](auto* download, auto) { return resolve_path_or_throw(download->file_list()->root_dir()); }); CMD2_DL_STRING_V("d.directory.set", std::bind(&apply_d_directory, std::placeholders::_1, std::placeholders::_2)); CMD2_DL ("d.directory_base", CMD2_ON_FL(root_dir)); CMD2_DL_STRING_V("d.directory_base.set", std::bind(&core::Download::set_root_directory, std::placeholders::_1, std::placeholders::_2)); @@ -912,6 +921,14 @@ initialize_command_download() { rpc::rpc.mark_safe("d.local_id_html"); rpc::rpc.mark_safe("d.bitfield"); rpc::rpc.mark_safe("d.base_path"); + rpc::rpc.mark_safe("d.base_path.realpath.or_empty"); + rpc::rpc.mark_safe("d.base_path.realpath.or_throw"); + rpc::rpc.mark_safe("d.directory.realpath.or_empty"); + rpc::rpc.mark_safe("d.directory.realpath.or_throw"); + rpc::rpc.mark_safe("d.tied_to_file.realpath.or_empty"); + rpc::rpc.mark_safe("d.tied_to_file.realpath.or_throw"); + rpc::rpc.mark_safe("d.loaded_file.realpath.or_empty"); + rpc::rpc.mark_safe("d.loaded_file.realpath.or_throw"); rpc::rpc.mark_safe("d.base_path.hex"); rpc::rpc.mark_safe("d.base_path.base64"); rpc::rpc.mark_safe("d.base_path.base64_as_binary"); diff --git a/src/command_file.cc b/src/command_file.cc index a72c1e6f..18af69de 100644 --- a/src/command_file.cc +++ b/src/command_file.cc @@ -116,6 +116,8 @@ initialize_command_file() { CMD2_FILE("f.frozen_path.as_binary", [](auto* file, auto) { return file->frozen_path().object_as_binary(); }); CMD2_FILE("f.frozen_path.or_base64", [](auto* file, auto) { return file->frozen_path().object_utf8_or_base64(); }); CMD2_FILE("f.frozen_path.or_as_binary", [](auto* file, auto) { return file->frozen_path().object_utf8_or_as_binary(); }); + CMD2_FILE("f.frozen_path.realpath.or_empty", [](auto* file, auto) { return resolve_path(file->frozen_path().str()); }); + CMD2_FILE("f.frozen_path.realpath.or_throw", [](auto* file, auto) { return resolve_path_or_throw(file->frozen_path().str()); }); CMD2_FILE("f.match_depth_prev", std::bind(&torrent::File::match_depth_prev, std::placeholders::_1)); CMD2_FILE("f.match_depth_next", std::bind(&torrent::File::match_depth_next, std::placeholders::_1)); @@ -129,6 +131,8 @@ initialize_command_file() { rpc::rpc.mark_safe("f.path_components"); rpc::rpc.mark_safe("f.path_depth"); rpc::rpc.mark_safe("f.frozen_path"); + rpc::rpc.mark_safe("f.frozen_path.realpath.or_empty"); + rpc::rpc.mark_safe("f.frozen_path.realpath.or_throw"); rpc::rpc.mark_safe("f.frozen_path.hex"); rpc::rpc.mark_safe("f.frozen_path.base64"); rpc::rpc.mark_safe("f.frozen_path.base64_as_binary"); diff --git a/src/command_helpers.cc b/src/command_helpers.cc index 4ef0755e..512d1a6a 100644 --- a/src/command_helpers.cc +++ b/src/command_helpers.cc @@ -19,6 +19,7 @@ void initialize_command_groups(); void initialize_command_throttle(); void initialize_command_tracker(); void initialize_command_scheduler(); +void initialize_command_string(); void initialize_command_ui(); void @@ -37,4 +38,5 @@ initialize_commands() { initialize_command_throttle(); initialize_command_tracker(); initialize_command_scheduler(); + initialize_command_string(); } diff --git a/src/command_local.cc b/src/command_local.cc index d1c44d87..2a7d983b 100644 --- a/src/command_local.cc +++ b/src/command_local.cc @@ -258,6 +258,8 @@ initialize_command_local() { continue; } + CMD_ANY (category_name + ".max_alloc.limit", [category](auto, auto) { return torrent::runtime::socket_manager()->category_alloc_limit(category); }); + CMD_ANY (category_name + ".min_alloc.limit", [category](auto, auto) { return torrent::runtime::socket_manager()->category_alloc_minimum(category); }); CMD_ANY (category_name + ".min_alloc", [category](auto, auto) { return torrent::runtime::socket_manager()->category_min_allocation(category); }); CMD_ANY (category_name + ".max_alloc", [category](auto, auto) { return torrent::runtime::socket_manager()->category_max_allocation(category); }); CMD_ANY_VALUE_V(category_name + ".min_alloc.set", [category](auto, auto& value) { torrent::runtime::socket_manager()->set_category_min_allocation(category, value); }); @@ -296,9 +298,13 @@ initialize_command_local() { CMD_VAR_BOOL ("pieces.hash.on_completion", true); CMD_VAR_STRING ("directory.default", "./"); + CMD_ANY ("directory.default.realpath.or_empty", [](auto, auto) { return resolve_path(rpc::call_command_string("directory.default")); }); + CMD_ANY ("directory.default.realpath.or_throw", [](auto, auto) { return resolve_path_or_throw(rpc::call_command_string("directory.default")); }); CMD_VAR_STRING ("session.name", ""); CMD_ANY ("session.path", [](auto, auto) { return session_thread::manager()->path(); }); + CMD_ANY ("session.path.realpath.or_empty", [](auto, auto) { return resolve_path(session_thread::manager()->path()); }); + CMD_ANY ("session.path.realpath.or_throw", [](auto, auto) { return resolve_path_or_throw(session_thread::manager()->path()); }); CMD_ANY_STRING_V("session.path.set", [](auto, auto& str) { return session_thread::manager()->set_path(str); }); CMD_ANY ("session.use_lock", [](auto, auto) { return session_thread::manager()->use_lock(); }); CMD_ANY_VALUE_V ("session.use_lock.set", [](auto, auto& value) { return session_thread::manager()->set_use_lock(value); }); @@ -366,12 +372,18 @@ initialize_command_local() { continue; } + rpc::rpc.mark_safe(category_name + ".max_alloc.limit"); + rpc::rpc.mark_safe(category_name + ".min_alloc.limit"); rpc::rpc.mark_safe(category_name + ".min_alloc"); rpc::rpc.mark_safe(category_name + ".max_alloc"); } rpc::rpc.mark_safe("directory.default"); rpc::rpc.mark_safe("session.path"); + rpc::rpc.mark_safe("session.path.realpath.or_empty"); + rpc::rpc.mark_safe("session.path.realpath.or_throw"); + rpc::rpc.mark_safe("directory.default.realpath.or_empty"); + rpc::rpc.mark_safe("directory.default.realpath.or_throw"); rpc::rpc.mark_safe("session.use_lock"); rpc::rpc.mark_safe("session.on_completion"); diff --git a/src/command_string.cc b/src/command_string.cc new file mode 100644 index 00000000..0a7d7062 --- /dev/null +++ b/src/command_string.cc @@ -0,0 +1,359 @@ +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rpc/parse.h" +#include "rpc/rpc_manager.h" + +#include "globals.h" +#include "control.h" +#include "command_helpers.h" + +namespace { + +const std::string whitespace_characters = " \t\n\r\f\v"; + +// The byte offset of every utf-8 character in 'text', terminated by the offset +// past the last character. Bytes that are not valid utf-8 lead bytes are +// treated as single characters. +std::vector +utf8_offsets(const std::string& text) { + std::vector offsets; + + for (size_t i = 0; i < text.size(); i++) + if (i == 0 || (static_cast(text[i]) & 0xC0) != 0x80) + offsets.push_back(i); + + offsets.push_back(text.size()); + return offsets; +} + +int64_t +utf8_length(const std::string& text) { + int64_t result = 0; + + for (size_t i = 0; i < text.size(); i++) + if (i == 0 || (static_cast(text[i]) & 0xC0) != 0x80) + result++; + + return result; +} + +std::string +utf8_substr(const std::string& text, const std::vector& offsets, size_t first, size_t last) { + return text.substr(offsets[first], offsets[last] - offsets[first]); +} + +std::set +utf8_character_set(const std::string& text) { + auto offsets = utf8_offsets(text); + std::set result; + + for (size_t i = 0; i + 1 < offsets.size(); i++) + result.insert(utf8_substr(text, offsets, i, i + 1)); + + return result; +} + +std::string +ascii_lowercase(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return std::tolower(c); }); + return text; +} + +// A 'max_count' of zero means the command accepts any number of arguments. +void +verify_argument_count(const char* name, const torrent::Object::list_type& args, size_t min_count, size_t max_count) { + if (args.size() < min_count || (max_count != 0 && args.size() > max_count)) + throw torrent::input_error(std::string(name) + ": invalid number of arguments."); +} + +const torrent::Object& +argument_at(const torrent::Object::list_type& args, size_t index) { + return *std::next(args.begin(), index); +} + +void +flatten_argument(const torrent::Object& arg, std::vector* dest) { + if (!arg.is_list()) { + dest->push_back(rpc::convert_to_string(arg)); + return; + } + + for (const auto& child : arg.as_list()) + flatten_argument(child, dest); +} + +// The {old, new} pairs shared by 'string.map' and 'string.replace'. +std::pair +argument_to_pair(const char* name, const torrent::Object& arg) { + if (!arg.is_list() || arg.as_list().size() != 2) + throw torrent::input_error(std::string(name) + ": arguments after the text must be {old, new} pairs."); + + return {rpc::convert_to_string(arg.as_list().front()), rpc::convert_to_string(arg.as_list().back())}; +} + +// Compares the first argument against every remaining argument, returning true +// as soon as one of them matches. +torrent::Object +apply_string_predicate(const char* name, const torrent::Object::list_type& args, bool (*predicate)(const std::string&, const std::string&)) { + verify_argument_count(name, args, 2, 0); + + auto text = rpc::convert_to_string(args.front()); + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) + if (predicate(text, rpc::convert_to_string(*itr))) + return int64_t(1); + + return int64_t(0); +} + +bool +text_equals(const std::string& text, const std::string& other) { + return text == other; +} + +bool +text_starts_with(const std::string& text, const std::string& prefix) { + return text.size() >= prefix.size() && text.compare(0, prefix.size(), prefix) == 0; +} + +bool +text_ends_with(const std::string& text, const std::string& tail) { + return text.size() >= tail.size() && text.compare(text.size() - tail.size(), tail.size(), tail) == 0; +} + +bool +text_contains(const std::string& text, const std::string& needle) { + return text.find(needle) != std::string::npos; +} + +bool +text_contains_i(const std::string& text, const std::string& needle) { + return ascii_lowercase(text).find(ascii_lowercase(needle)) != std::string::npos; +} + +torrent::Object +apply_string_length(const torrent::Object::list_type& args) { + verify_argument_count("string.length", args, 1, 1); + + return utf8_length(rpc::convert_to_string(args.front())); +} + +torrent::Object +apply_string_substr(const torrent::Object::list_type& args) { + verify_argument_count("string.substr", args, 1, 4); + + auto text = rpc::convert_to_string(args.front()); + auto offsets = utf8_offsets(text); + auto text_length = static_cast(offsets.size() - 1); + + auto position = args.size() > 1 ? rpc::convert_to_value(argument_at(args, 1)) : 0; + auto fallback = args.size() > 3 ? rpc::convert_to_string(argument_at(args, 3)) : std::string(); + + // Negative positions are relative to the end of the string. + if (position < 0) + position += text_length; + + if (position < 0 || position > text_length) + return fallback; + + auto last = text_length; + + if (args.size() > 2) { + auto count = rpc::convert_to_value(argument_at(args, 2)); + + if (count < 0) + throw torrent::input_error("string.substr: the character count cannot be negative."); + + last = std::min(text_length, position + std::min(count, text_length)); + } + + return utf8_substr(text, offsets, position, last); +} + +torrent::Object +apply_string_split(const torrent::Object::list_type& args) { + verify_argument_count("string.split", args, 2, 2); + + auto text = rpc::convert_to_string(args.front()); + auto delim = rpc::convert_to_string(args.back()); + auto result = torrent::Object::create_list(); + + // An empty delimiter splits the text into its utf-8 characters. + if (delim.empty()) { + auto offsets = utf8_offsets(text); + + for (size_t i = 0; i + 1 < offsets.size(); i++) + result.as_list().push_back(utf8_substr(text, offsets, i, i + 1)); + + return result; + } + + size_t first = 0; + + while (true) { + auto pos = text.find(delim, first); + + if (pos == std::string::npos) { + result.as_list().push_back(text.substr(first)); + return result; + } + + result.as_list().push_back(text.substr(first, pos - first)); + first = pos + delim.size(); + } +} + +torrent::Object +apply_string_join(const torrent::Object::list_type& args) { + verify_argument_count("string.join", args, 1, 0); + + auto delim = rpc::convert_to_string(args.front()); + + std::vector parts; + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) + flatten_argument(*itr, &parts); + + std::string result; + + for (auto itr = parts.begin(); itr != parts.end(); itr++) { + if (itr != parts.begin()) + result += delim; + + result += *itr; + } + + return result; +} + +torrent::Object +apply_string_pad(const char* name, const torrent::Object::list_type& args, bool pad_start) { + verify_argument_count(name, args, 2, 3); + + auto text = rpc::convert_to_string(args.front()); + auto pad_size = rpc::convert_to_value(argument_at(args, 1)); + auto padding = args.size() > 2 ? rpc::convert_to_string(argument_at(args, 2)) : std::string(" "); + + auto text_length = utf8_length(text); + + if (pad_size <= text_length || padding.empty()) + return text; + + auto padding_offsets = utf8_offsets(padding); + auto padding_length = static_cast(padding_offsets.size() - 1); + + std::string result; + + for (int64_t i = 0; i < pad_size - text_length; i++) { + auto index = static_cast(i % padding_length); + result += utf8_substr(padding, padding_offsets, index, index + 1); + } + + return pad_start ? result + text : text + result; +} + +torrent::Object +apply_string_strip(const char* name, const torrent::Object::list_type& args, bool strip_start, bool strip_end) { + verify_argument_count(name, args, 1, 0); + + auto text = rpc::convert_to_string(args.front()); + + std::string strippable; + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) + strippable += rpc::convert_to_string(*itr); + + if (args.size() == 1) + strippable = whitespace_characters; + + auto characters = utf8_character_set(strippable); + auto offsets = utf8_offsets(text); + + size_t first = 0; + size_t last = offsets.size() - 1; + + while (strip_start && first < last && characters.count(utf8_substr(text, offsets, first, first + 1)) != 0) + first++; + + while (strip_end && last > first && characters.count(utf8_substr(text, offsets, last - 1, last)) != 0) + last--; + + return utf8_substr(text, offsets, first, last); +} + +torrent::Object +apply_string_map(const torrent::Object::list_type& args) { + verify_argument_count("string.map", args, 2, 0); + + auto text = rpc::convert_to_string(args.front()); + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) { + auto pair = argument_to_pair("string.map", *itr); + + if (text == pair.first) + return pair.second; + } + + return text; +} + +torrent::Object +apply_string_replace(const torrent::Object::list_type& args) { + verify_argument_count("string.replace", args, 2, 0); + + auto text = rpc::convert_to_string(args.front()); + + for (auto itr = std::next(args.begin()); itr != args.end(); itr++) { + auto pair = argument_to_pair("string.replace", *itr); + + if (pair.first.empty()) + throw torrent::input_error("string.replace: the replaced text cannot be empty."); + + for (auto pos = text.find(pair.first); pos != std::string::npos; pos = text.find(pair.first, pos + pair.second.size())) + text.replace(pos, pair.first.size(), pair.second); + } + + return text; +} + +} + +void +initialize_command_string() { + // clang-format off + CMD2_ANY_LIST("string.length", [](auto, const auto& args) { return apply_string_length(args); }); + CMD2_ANY_LIST("string.substr", [](auto, const auto& args) { return apply_string_substr(args); }); + CMD2_ANY_LIST("string.split", [](auto, const auto& args) { return apply_string_split(args); }); + CMD2_ANY_LIST("string.join", [](auto, const auto& args) { return apply_string_join(args); }); + CMD2_ANY_LIST("string.map", [](auto, const auto& args) { return apply_string_map(args); }); + CMD2_ANY_LIST("string.replace", [](auto, const auto& args) { return apply_string_replace(args); }); + + CMD2_ANY_LIST("string.equals", [](auto, const auto& args) { return apply_string_predicate("string.equals", args, &text_equals); }); + CMD2_ANY_LIST("string.starts_with", [](auto, const auto& args) { return apply_string_predicate("string.starts_with", args, &text_starts_with); }); + CMD2_ANY_LIST("string.ends_with", [](auto, const auto& args) { return apply_string_predicate("string.ends_with", args, &text_ends_with); }); + CMD2_ANY_LIST("string.contains", [](auto, const auto& args) { return apply_string_predicate("string.contains", args, &text_contains); }); + CMD2_ANY_LIST("string.contains_i", [](auto, const auto& args) { return apply_string_predicate("string.contains_i", args, &text_contains_i); }); + + CMD2_ANY_LIST("string.lpad", [](auto, const auto& args) { return apply_string_pad("string.lpad", args, true); }); + CMD2_ANY_LIST("string.rpad", [](auto, const auto& args) { return apply_string_pad("string.rpad", args, false); }); + + CMD2_ANY_LIST("string.strip", [](auto, const auto& args) { return apply_string_strip("string.strip", args, true, true); }); + CMD2_ANY_LIST("string.lstrip", [](auto, const auto& args) { return apply_string_strip("string.lstrip", args, true, false); }); + CMD2_ANY_LIST("string.rstrip", [](auto, const auto& args) { return apply_string_strip("string.rstrip", args, false, true); }); + // clang-format on + + for (const auto name : {"string.length", "string.substr", "string.split", "string.join", "string.map", "string.replace", + "string.equals", "string.starts_with", "string.ends_with", "string.contains", "string.contains_i", + "string.lpad", "string.rpad", "string.strip", "string.lstrip", "string.rstrip"}) + rpc::rpc.mark_safe(name); +} diff --git a/src/core/download_factory.cc b/src/core/download_factory.cc index 2d030042..a243066c 100644 --- a/src/core/download_factory.cc +++ b/src/core/download_factory.cc @@ -35,8 +35,10 @@ is_network_uri(const std::string& uri) { std::strncmp(uri.c_str(), "ftp://", 6) == 0; } +static constexpr const char* session_invalid_message = "Session data is invalid, ignoring it"; + static std::unique_ptr -download_factory_load_stream(const char* filename) { +download_factory_load_stream(const char* filename, bool* is_invalid) { std::fstream stream(filename, std::ios::in | std::ios::binary); if (!stream.is_open()) @@ -45,8 +47,10 @@ download_factory_load_stream(const char* filename) { auto obj = std::make_unique(); stream >> *obj; - if (!stream.good()) + if (!stream.good() || !obj->is_map()) { + *is_invalid = true; return std::unique_ptr(); + } return obj; } @@ -161,8 +165,13 @@ DownloadFactory::receive_commit() { void DownloadFactory::receive_success() { - auto rtorrent_object = download_factory_load_stream((expand_path(m_uri) + ".rtorrent").c_str()); - auto libtorrent_resume_object = download_factory_load_stream((expand_path(m_uri) + ".libtorrent_resume").c_str()); + bool session_invalid = false; + + auto rtorrent_object = download_factory_load_stream((expand_path(m_uri) + ".rtorrent").c_str(), &session_invalid); + auto libtorrent_resume_object = download_factory_load_stream((expand_path(m_uri) + ".libtorrent_resume").c_str(), &session_invalid); + + if (session_invalid) + lt_log_print(torrent::LOG_ERROR, "%s: %s", session_invalid_message, m_uri.c_str()); uint32_t tracker_key; @@ -184,6 +193,14 @@ DownloadFactory::receive_success() { return; } + if (session_invalid) { + download->set_hash_failed(true); + download->set_message(session_invalid_message); + + if (m_printLog) + m_manager->push_log_std(std::string(session_invalid_message) + ": \"" + m_uri + "\""); + } + torrent::Object* root = download->bencode(); if (download->download()->info()->is_meta_download()) { @@ -274,9 +291,22 @@ DownloadFactory::receive_success() { rpc::call_command("d.peer_exchange.set", torrent::runtime::client_config()->is_pex_enabled(), rpc::make_target(download)); - torrent::resume_load_addresses(*download->download(), resumeObject); - torrent::resume_load_file_priorities(*download->download(), resumeObject); - torrent::resume_load_tracker_settings(*download->download(), resumeObject); + try { + torrent::resume_load_addresses(*download->download(), resumeObject); + torrent::resume_load_file_priorities(*download->download(), resumeObject); + torrent::resume_load_tracker_settings(*download->download(), resumeObject); + + } catch (const torrent::input_error& e) { + std::string msg = std::string(session_invalid_message) + ": " + e.what(); + + lt_log_print(torrent::LOG_ERROR, "%s: %s", msg.c_str(), m_uri.c_str()); + + if (m_printLog) + m_manager->push_log_std(msg + ": \"" + m_uri + "\""); + + download->set_hash_failed(true); + download->set_message(msg); + } // The action of inserting might cause the torrent to be // opened/started or such. Figure out a nicer way of handling this. diff --git a/src/display/utils.cc b/src/display/utils.cc index cef642ab..fa6acfd1 100644 --- a/src/display/utils.cc +++ b/src/display/utils.cc @@ -345,7 +345,7 @@ print_status_info(char* first, char* last) { if (!torrent::up_throttle_global()->is_throttled()) { first = print_buffer(first, last, "[Throttle off"); } else { - first = print_buffer(first, last, "[Throttle %3i", torrent::up_throttle_global()->max_rate() / 1024); + first = print_buffer(first, last, "[Throttle %3i", (int)(torrent::up_throttle_global()->max_rate() / 1024)); if (!throttle_up_names.empty()) first = print_status_throttle_limit(first, last, true, throttle_up_names); @@ -354,7 +354,7 @@ print_status_info(char* first, char* last) { if (!torrent::down_throttle_global()->is_throttled()) { first = print_buffer(first, last, " / off KB]"); } else { - first = print_buffer(first, last, " / %3i", torrent::down_throttle_global()->max_rate() / 1024); + first = print_buffer(first, last, " / %3i", (int)(torrent::down_throttle_global()->max_rate() / 1024)); if (!throttle_down_names.empty()) first = print_status_throttle_limit(first, last, false, throttle_down_names); diff --git a/src/globals.cc b/src/globals.cc index 5cb62635..25c85b52 100644 --- a/src/globals.cc +++ b/src/globals.cc @@ -3,6 +3,7 @@ #include "globals.h" #include +#include #include rpc::ip_table_list ip_tables; @@ -28,3 +29,32 @@ expand_path(const std::string& path) { return path; } + +// Resolves a path to a canonical one with no symlinks or relative components, +// so it can safely be handed to an external script. Returns an empty string if +// the path does not name an existing file or directory. +std::string +resolve_path(const std::string& path) { + if (path.empty()) + return std::string(); + + char* resolved = ::realpath(expand_path(path).c_str(), nullptr); + + if (resolved == nullptr) + return std::string(); + + std::string result(resolved); + std::free(resolved); + + return result; +} + +std::string +resolve_path_or_throw(const std::string& path) { + auto result = resolve_path(path); + + if (result.empty()) + throw torrent::input_error("Could not resolve path: '" + path + "'."); + + return result; +} diff --git a/src/globals.h b/src/globals.h index 7d5cc9e5..ca9a3c6f 100644 --- a/src/globals.h +++ b/src/globals.h @@ -11,6 +11,8 @@ extern rpc::ip_table_list ip_tables; extern Control* control; std::string expand_path(const std::string& path); +std::string resolve_path(const std::string& path); +std::string resolve_path_or_throw(const std::string& path); namespace rpc { class SCgi; diff --git a/src/rpc/xmlrpc_c.cc b/src/rpc/xmlrpc_c.cc index 83efae78..ab04166d 100644 --- a/src/rpc/xmlrpc_c.cc +++ b/src/rpc/xmlrpc_c.cc @@ -451,6 +451,11 @@ XmlRpc::process(const char* inBuffer, uint32_t length, slot_write slotWrite) { if (local_env.fault_occurred && local_env.fault_code == XMLRPC_INTERNAL_ERROR) throw torrent::internal_error("Internal error in XMLRPC."); + if (memblock == nullptr) { + xmlrpc_env_clean(&local_env); + return false; + } + bool result = slotWrite((const char*)xmlrpc_mem_block_contents(memblock), xmlrpc_mem_block_size(memblock)); diff --git a/src/ui/root.cc b/src/ui/root.cc index 9e18d289..84f942e3 100644 --- a/src/ui/root.cc +++ b/src/ui/root.cc @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -354,8 +355,9 @@ Root::reset_input_history_attributes(ui::DownloadList::Input type) { void Root::set_input_history_size(int size) { - if (size < 1) - throw torrent::input_error("Invalid input history size."); + if (size < 1 || size > max_input_history_size) + throw torrent::input_error("Input history size must be between 1 and " + + std::to_string(max_input_history_size) + "."); for (auto& [entry, category] : m_input_history) { // Reserve the latest input history entries if new size is smaller than original. diff --git a/src/ui/root.h b/src/ui/root.h index 6f4d5717..95c4cbca 100644 --- a/src/ui/root.h +++ b/src/ui/root.h @@ -54,6 +54,8 @@ public: typedef std::vector InputHistoryCategory; typedef std::map InputHistory; + static constexpr int max_input_history_size = 4096; + Root(); void init(Control* c); diff --git a/test/Makefile.am b/test/Makefile.am index d6eb396e..599d1b3b 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -51,6 +51,10 @@ rtorrent_Test_Src_SOURCES = $(rtorrent_Test_Common) \ src/test_command_dynamic.h \ src/test_command_local.cc \ src/test_command_local.h \ + src/test_command_path.cc \ + src/test_command_path.h \ + src/test_command_string.cc \ + src/test_command_string.h \ src/test_watch_ready_queue.cc \ src/test_watch_ready_queue.h diff --git a/test/src/test_command_path.cc b/test/src/test_command_path.cc new file mode 100644 index 00000000..6f07b981 --- /dev/null +++ b/test/src/test_command_path.cc @@ -0,0 +1,116 @@ +#include "config.h" + +#include "test/src/test_command_path.h" + +#include +#include +#include +#include +#include + +#include "control.h" +#include "globals.h" +#include "rpc/parse_commands.h" + +CPPUNIT_TEST_SUITE_REGISTRATION(TestCommandPath); + +void initialize_command_local(); + +void +TestCommandPath::setUp() { + char temp_dir[] = "/tmp/rtorrent_test_path_XXXXXX"; + + CPPUNIT_ASSERT(mkdtemp(temp_dir) != nullptr); + + // The temporary directory itself may sit behind a symlink, as /tmp does on + // macOS, so resolve it up front to keep the expected values exact. + m_temp_dir = resolve_path(temp_dir); + + CPPUNIT_ASSERT(!m_temp_dir.empty()); + CPPUNIT_ASSERT_EQUAL(0, mkdir((m_temp_dir + "/data").c_str(), 0755)); + CPPUNIT_ASSERT_EQUAL(0, symlink((m_temp_dir + "/data").c_str(), (m_temp_dir + "/link").c_str())); +} + +void +TestCommandPath::tearDown() { + unlink((m_temp_dir + "/link").c_str()); + rmdir((m_temp_dir + "/data").c_str()); + rmdir(m_temp_dir.c_str()); +} + +void +TestCommandPath::test_resolves_symlink() { + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", resolve_path(m_temp_dir + "/link")); + + // A path that lies below a symlinked directory is resolved as well. + CPPUNIT_ASSERT_EQUAL(0, mkdir((m_temp_dir + "/data/below").c_str(), 0755)); + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data/below", resolve_path(m_temp_dir + "/link/below")); + rmdir((m_temp_dir + "/data/below").c_str()); +} + +void +TestCommandPath::test_removes_relative_components() { + CPPUNIT_ASSERT_EQUAL(m_temp_dir, resolve_path(m_temp_dir + "/data/..")); + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", resolve_path(m_temp_dir + "/./data")); + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", resolve_path(m_temp_dir + "/data/")); + + // Trailing slashes and duplicated separators collapse. + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", resolve_path(m_temp_dir + "//data//")); +} + +void +TestCommandPath::test_expands_tilde() { + const char* home = std::getenv("HOME"); + + if (home == nullptr || *home == '\0') + return; + + CPPUNIT_ASSERT_EQUAL(resolve_path(home), resolve_path("~")); + CPPUNIT_ASSERT_THROW(resolve_path("~root/somewhere"), torrent::input_error); +} + +void +TestCommandPath::test_missing_path_throws() { + CPPUNIT_ASSERT_THROW(resolve_path_or_throw(""), torrent::input_error); + CPPUNIT_ASSERT_THROW(resolve_path_or_throw(m_temp_dir + "/does_not_exist"), torrent::input_error); + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", resolve_path_or_throw(m_temp_dir + "/link")); +} + +void +TestCommandPath::test_missing_path_is_empty() { + CPPUNIT_ASSERT_EQUAL(std::string(), resolve_path("")); + CPPUNIT_ASSERT_EQUAL(std::string(), resolve_path(m_temp_dir + "/does_not_exist")); + CPPUNIT_ASSERT_EQUAL(std::string(), resolve_path(m_temp_dir + "/does_not_exist/below")); + + // A dangling symlink does not name an existing path either. + CPPUNIT_ASSERT_EQUAL(0, symlink((m_temp_dir + "/gone").c_str(), (m_temp_dir + "/dangling").c_str())); + CPPUNIT_ASSERT_EQUAL(std::string(), resolve_path(m_temp_dir + "/dangling")); + unlink((m_temp_dir + "/dangling").c_str()); +} + +void +TestCommandPath::test_commands() { + torrent::initialize_main_thread(); + torrent::initialize(); + + if (control == nullptr) + control = new Control; + + if (!rpc::commands.has("directory.default.realpath.or_empty")) + initialize_command_local(); + + rpc::commands.call_command("directory.default.set", m_temp_dir + "/link"); + + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/link", rpc::commands.call_command("directory.default", torrent::Object()).as_string()); + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", rpc::commands.call_command("directory.default.realpath.or_empty", torrent::Object()).as_string()); + CPPUNIT_ASSERT_EQUAL(m_temp_dir + "/data", rpc::commands.call_command("directory.default.realpath.or_throw", torrent::Object()).as_string()); + + // A directory that has not been created yet resolves to nothing, and the + // or_throw variant reports it instead. + rpc::commands.call_command("directory.default.set", m_temp_dir + "/missing"); + + CPPUNIT_ASSERT_EQUAL(std::string(), rpc::commands.call_command("directory.default.realpath.or_empty", torrent::Object()).as_string()); + CPPUNIT_ASSERT_THROW(rpc::commands.call_command("directory.default.realpath.or_throw", torrent::Object()), torrent::input_error); + + torrent::cleanup(); +} diff --git a/test/src/test_command_path.h b/test/src/test_command_path.h new file mode 100644 index 00000000..041815c9 --- /dev/null +++ b/test/src/test_command_path.h @@ -0,0 +1,30 @@ +#include "test/helpers/test_fixture.h" + +#include + +class TestCommandPath : public test_fixture { + CPPUNIT_TEST_SUITE(TestCommandPath); + + CPPUNIT_TEST(test_resolves_symlink); + CPPUNIT_TEST(test_removes_relative_components); + CPPUNIT_TEST(test_expands_tilde); + CPPUNIT_TEST(test_missing_path_is_empty); + CPPUNIT_TEST(test_missing_path_throws); + CPPUNIT_TEST(test_commands); + + CPPUNIT_TEST_SUITE_END(); + +public: + void setUp(); + void tearDown(); + + void test_resolves_symlink(); + void test_removes_relative_components(); + void test_expands_tilde(); + void test_missing_path_is_empty(); + void test_missing_path_throws(); + void test_commands(); + +private: + std::string m_temp_dir; +}; diff --git a/test/src/test_command_string.cc b/test/src/test_command_string.cc new file mode 100644 index 00000000..640ca8bc --- /dev/null +++ b/test/src/test_command_string.cc @@ -0,0 +1,261 @@ +#include "config.h" + +#include "test/src/test_command_string.h" + +#include "rpc/parse_commands.h" + +CPPUNIT_TEST_SUITE_REGISTRATION(TestCommandString); + +void initialize_command_string(); + +namespace { + +torrent::Object +args(std::initializer_list objects) { + auto result = torrent::Object::create_list(); + + for (const auto& object : objects) + result.as_list().push_back(object); + + return result; +} + +std::string +call_string(const char* key, std::initializer_list objects) { + return rpc::commands.call_command(key, args(objects)).as_string(); +} + +int64_t +call_value(const char* key, std::initializer_list objects) { + return rpc::commands.call_command(key, args(objects)).as_value(); +} + +torrent::Object::list_type +call_list(const char* key, std::initializer_list objects) { + return rpc::commands.call_command(key, args(objects)).as_list(); +} + +// Runs a command the way a line in the configuration file would. +torrent::Object +parse(const char* command) { + return rpc::parse_command_single(rpc::make_target(), command); +} + +} + +void +TestCommandString::setUp() { + if (!rpc::commands.has("string.length")) + initialize_command_string(); +} + +void +TestCommandString::tearDown() { +} + +void +TestCommandString::test_length() { + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.length", {""})); + CPPUNIT_ASSERT_EQUAL(int64_t(3), call_value("string.length", {"abc"})); + + // The length is counted in utf-8 characters, not bytes. + CPPUNIT_ASSERT_EQUAL(int64_t(5), call_value("string.length", {"héllo"})); + CPPUNIT_ASSERT_EQUAL(int64_t(3), call_value("string.length", {"日本語"})); + + // Values are converted to their string representation. + CPPUNIT_ASSERT_EQUAL(int64_t(4), call_value("string.length", {int64_t(1234)})); +} + +void +TestCommandString::test_equals() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.equals", {"abc", "abc"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.equals", {"abc", "abd"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.equals", {"abc", "ab"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.equals", {"abc", "x", "abc"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.equals", {"abc", "x", "y"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.equals", {int64_t(42), "42"})); +} + +void +TestCommandString::test_starts_with() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.starts_with", {"abcdef", "abc"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.starts_with", {"abcdef", ""})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.starts_with", {"abcdef", "bcd"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.starts_with", {"ab", "abc"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.starts_with", {"abcdef", "x", "ab"})); +} + +void +TestCommandString::test_ends_with() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.ends_with", {"abcdef", "def"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.ends_with", {"abcdef", ""})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.ends_with", {"abcdef", "cde"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.ends_with", {"ef", "def"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.ends_with", {"a.torrent", ".rar", ".torrent"})); +} + +void +TestCommandString::test_contains() { + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains", {"abcdef", "cde"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.contains", {"abcdef", "ace"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains", {"abcdef", "x", "bcd"})); + + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.contains", {"retracker.local", "RETRACKER"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains_i", {"retracker.local", "RETRACKER"})); + CPPUNIT_ASSERT_EQUAL(int64_t(1), call_value("string.contains_i", {"RETRACKER.LOCAL", "retracker"})); + CPPUNIT_ASSERT_EQUAL(int64_t(0), call_value("string.contains_i", {"abcdef", "xyz"})); +} + +void +TestCommandString::test_substr() { + CPPUNIT_ASSERT_EQUAL(std::string("abcdef"), call_string("string.substr", {"abcdef"})); + CPPUNIT_ASSERT_EQUAL(std::string("cdef"), call_string("string.substr", {"abcdef", int64_t(2)})); + CPPUNIT_ASSERT_EQUAL(std::string("cde"), call_string("string.substr", {"abcdef", int64_t(2), int64_t(3)})); + CPPUNIT_ASSERT_EQUAL(std::string("cdef"), call_string("string.substr", {"abcdef", int64_t(2), int64_t(100)})); + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.substr", {"abcdef", int64_t(2), int64_t(0)})); + + // Negative positions are relative to the end of the string. + CPPUNIT_ASSERT_EQUAL(std::string("ef"), call_string("string.substr", {"abcdef", int64_t(-2)})); + CPPUNIT_ASSERT_EQUAL(std::string("e"), call_string("string.substr", {"abcdef", int64_t(-2), int64_t(1)})); + + // Out-of-bounds positions return the default value. + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.substr", {"abcdef", int64_t(10)})); + CPPUNIT_ASSERT_EQUAL(std::string("n/a"), call_string("string.substr", {"abcdef", int64_t(10), int64_t(1), "n/a"})); + CPPUNIT_ASSERT_EQUAL(std::string("n/a"), call_string("string.substr", {"abcdef", int64_t(-10), int64_t(1), "n/a"})); + + // Positions and counts are in utf-8 characters, not bytes. + CPPUNIT_ASSERT_EQUAL(std::string("本"), call_string("string.substr", {"日本語", int64_t(1), int64_t(1)})); + CPPUNIT_ASSERT_EQUAL(std::string("本語"), call_string("string.substr", {"日本語", int64_t(-2)})); +} + +void +TestCommandString::test_split() { + auto parts = call_list("string.split", {"a,b,c", ","}); + + CPPUNIT_ASSERT_EQUAL(size_t(3), parts.size()); + CPPUNIT_ASSERT_EQUAL(std::string("a"), parts.front().as_string()); + CPPUNIT_ASSERT_EQUAL(std::string("c"), parts.back().as_string()); + + // Empty fields are preserved. + CPPUNIT_ASSERT_EQUAL(size_t(3), call_list("string.split", {"a,,b", ","}).size()); + CPPUNIT_ASSERT_EQUAL(size_t(1), call_list("string.split", {"abc", ","}).size()); + + // A multi-character delimiter is matched as a whole. + CPPUNIT_ASSERT_EQUAL(size_t(2), call_list("string.split", {"a::b", "::"}).size()); + + // An empty delimiter splits the text into its utf-8 characters. + auto characters = call_list("string.split", {"日本語", ""}); + + CPPUNIT_ASSERT_EQUAL(size_t(3), characters.size()); + CPPUNIT_ASSERT_EQUAL(std::string("日"), characters.front().as_string()); +} + +void +TestCommandString::test_join() { + CPPUNIT_ASSERT_EQUAL(std::string("a, b"), call_string("string.join", {", ", "a", "b"})); + CPPUNIT_ASSERT_EQUAL(std::string("ab"), call_string("string.join", {"", "a", "b"})); + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.join", {", "})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.join", {", ", "a"})); + CPPUNIT_ASSERT_EQUAL(std::string("1-2"), call_string("string.join", {"-", int64_t(1), int64_t(2)})); + + // Lists are flattened, so the output of string.split can be joined again. + CPPUNIT_ASSERT_EQUAL(std::string("a-b-c"), call_string("string.join", {"-", args({"a", "b"}), "c"})); +} + +void +TestCommandString::test_pad() { + CPPUNIT_ASSERT_EQUAL(std::string(" 7"), call_string("string.lpad", {"7", int64_t(3)})); + CPPUNIT_ASSERT_EQUAL(std::string("7 "), call_string("string.rpad", {"7", int64_t(3)})); + CPPUNIT_ASSERT_EQUAL(std::string("007"), call_string("string.lpad", {"7", int64_t(3), "0"})); + CPPUNIT_ASSERT_EQUAL(std::string("700"), call_string("string.rpad", {"7", int64_t(3), "0"})); + + // Text that is already long enough is returned unchanged. + CPPUNIT_ASSERT_EQUAL(std::string("abcd"), call_string("string.lpad", {"abcd", int64_t(2)})); + CPPUNIT_ASSERT_EQUAL(std::string("abcd"), call_string("string.rpad", {"abcd", int64_t(4)})); + + // Multi-character padding is repeated, and an empty padding is a no-op. + CPPUNIT_ASSERT_EQUAL(std::string("axyx"), call_string("string.rpad", {"a", int64_t(4), "xy"})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.rpad", {"a", int64_t(4), ""})); + + // Padding is counted in utf-8 characters, not bytes. + CPPUNIT_ASSERT_EQUAL(std::string("00日"), call_string("string.lpad", {"日", int64_t(3), "0"})); +} + +void +TestCommandString::test_strip() { + CPPUNIT_ASSERT_EQUAL(std::string("a b"), call_string("string.strip", {" a b "})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"\t\n a \r\n"})); + CPPUNIT_ASSERT_EQUAL(std::string("a "), call_string("string.lstrip", {" a "})); + CPPUNIT_ASSERT_EQUAL(std::string(" a"), call_string("string.rstrip", {" a "})); + + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"xxaxx", "x"})); + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"/x/a/x/", "/", "x"})); + CPPUNIT_ASSERT_EQUAL(std::string(""), call_string("string.strip", {"aaa", "a"})); + CPPUNIT_ASSERT_EQUAL(std::string(" a "), call_string("string.strip", {" a ", ""})); + + // The strippable argument is a set of utf-8 characters. + CPPUNIT_ASSERT_EQUAL(std::string("a"), call_string("string.strip", {"日a日", "日"})); +} + +void +TestCommandString::test_map() { + CPPUNIT_ASSERT_EQUAL(std::string("b"), call_string("string.map", {"a", args({"a", "b"})})); + CPPUNIT_ASSERT_EQUAL(std::string("c"), call_string("string.map", {"c", args({"a", "b"})})); + + // Only whole-string matches are replaced. + CPPUNIT_ASSERT_EQUAL(std::string("ab"), call_string("string.map", {"ab", args({"a", "b"})})); + + // The first matching pair wins. + CPPUNIT_ASSERT_EQUAL(std::string("y"), call_string("string.map", {"x", args({"a", "b"}), args({"x", "y"}), args({"x", "z"})})); +} + +void +TestCommandString::test_replace() { + CPPUNIT_ASSERT_EQUAL(std::string("a+b+c"), call_string("string.replace", {"a-b-c", args({"-", "+"})})); + CPPUNIT_ASSERT_EQUAL(std::string("abc"), call_string("string.replace", {"a-b-c", args({"-", ""})})); + CPPUNIT_ASSERT_EQUAL(std::string("a-b-c"), call_string("string.replace", {"a-b-c", args({"x", "y"})})); + + // Pairs are applied in order, left to right. + CPPUNIT_ASSERT_EQUAL(std::string("xby"), call_string("string.replace", {"abc", args({"a", "x"}), args({"c", "y"})})); + + // A replacement that contains the replaced text terminates. + CPPUNIT_ASSERT_EQUAL(std::string("aaaa"), call_string("string.replace", {"aa", args({"a", "aa"})})); +} + +void +TestCommandString::test_invalid_arguments() { + CPPUNIT_ASSERT_THROW(rpc::commands.call_command("string.length", torrent::Object()), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.length", {"a", "b"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_value("string.equals", {"a"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_value("string.contains", {"a"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_list("string.split", {"a"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.lpad", {"a"}), torrent::input_error); + + // The character count of string.substr cannot be negative. + CPPUNIT_ASSERT_THROW(call_string("string.substr", {"abc", int64_t(0), int64_t(-1)}), torrent::input_error); + + // Both string.map and string.replace require {old, new} pairs. + CPPUNIT_ASSERT_THROW(call_string("string.map", {"a", "b"}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.replace", {"a", args({"a", "b", "c"})}), torrent::input_error); + CPPUNIT_ASSERT_THROW(call_string("string.replace", {"a", args({"", "b"})}), torrent::input_error); +} + +void +TestCommandString::test_config_syntax() { + CPPUNIT_ASSERT_EQUAL(int64_t(3), parse("string.length=abc").as_value()); + CPPUNIT_ASSERT_EQUAL(int64_t(1), parse("string.contains=retracker.local,retracker").as_value()); + CPPUNIT_ASSERT_EQUAL(int64_t(1), parse("string.starts_with=udp://tracker.example.com,http://,udp://").as_value()); + CPPUNIT_ASSERT_EQUAL(std::string("cde"), parse("string.substr=abcdef,2,3").as_string()); + CPPUNIT_ASSERT_EQUAL(std::string("padded"), parse("string.strip=\" padded \"").as_string()); + + // The {old, new} pairs are written as a block in the configuration file. + CPPUNIT_ASSERT_EQUAL(std::string("a+b+c"), parse("string.replace=a-b-c,{-,+}").as_string()); + CPPUNIT_ASSERT_EQUAL(std::string("y"), parse("string.map=x,{a,b},{x,y}").as_string()); + + // Commands nest, so a split can be joined back together. + CPPUNIT_ASSERT_EQUAL(std::string("a-b-c"), parse("string.join=-,(string.split,a.b.c,.)").as_string()); +} diff --git a/test/src/test_command_string.h b/test/src/test_command_string.h new file mode 100644 index 00000000..10f49d6a --- /dev/null +++ b/test/src/test_command_string.h @@ -0,0 +1,41 @@ +#include "test/helpers/test_fixture.h" + +class TestCommandString : public test_fixture { + CPPUNIT_TEST_SUITE(TestCommandString); + + CPPUNIT_TEST(test_length); + CPPUNIT_TEST(test_equals); + CPPUNIT_TEST(test_starts_with); + CPPUNIT_TEST(test_ends_with); + CPPUNIT_TEST(test_contains); + CPPUNIT_TEST(test_substr); + CPPUNIT_TEST(test_split); + CPPUNIT_TEST(test_join); + CPPUNIT_TEST(test_pad); + CPPUNIT_TEST(test_strip); + CPPUNIT_TEST(test_map); + CPPUNIT_TEST(test_replace); + CPPUNIT_TEST(test_invalid_arguments); + CPPUNIT_TEST(test_config_syntax); + + CPPUNIT_TEST_SUITE_END(); + +public: + void setUp(); + void tearDown(); + + void test_length(); + void test_equals(); + void test_starts_with(); + void test_ends_with(); + void test_contains(); + void test_substr(); + void test_split(); + void test_join(); + void test_pad(); + void test_strip(); + void test_map(); + void test_replace(); + void test_invalid_arguments(); + void test_config_syntax(); +};