From 31196ff5f080c5a36c25e1b36445b9faaf1f9b1e Mon Sep 17 00:00:00 2001 From: kunalkakade Date: Fri, 7 Aug 2026 14:52:12 +0530 Subject: [PATCH] Add realpath variants of the commands that return paths. Closes #1670. --- src/command_download.cc | 17 +++++ src/command_file.cc | 4 ++ src/command_local.cc | 8 +++ src/globals.cc | 30 +++++++++ src/globals.h | 2 + test/Makefile.am | 2 + test/src/test_command_path.cc | 116 ++++++++++++++++++++++++++++++++++ test/src/test_command_path.h | 30 +++++++++ 8 files changed, 209 insertions(+) create mode 100644 test/src/test_command_path.cc create mode 100644 test/src/test_command_path.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_local.cc b/src/command_local.cc index d1c44d87..893e5a5f 100644 --- a/src/command_local.cc +++ b/src/command_local.cc @@ -296,9 +296,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); }); @@ -372,6 +376,10 @@ initialize_command_local() { 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/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/test/Makefile.am b/test/Makefile.am index 94b4d919..599d1b3b 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -51,6 +51,8 @@ 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 \ 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; +};