diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ca25bc..27a2cfd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,13 +93,13 @@ if("$ENV{ROS_VERSION}" STREQUAL "1") message(STATUS "Building with catkin") set(ROS_BUILD_TYPE "catkin") - find_package(catkin REQUIRED COMPONENTS nodelet ros_babel_fish rosgraph_msgs roslib roscpp) + find_package(catkin REQUIRED COMPONENTS nodelet resource_retriever ros_babel_fish rosgraph_msgs roslib roscpp) find_package(Boost REQUIRED) catkin_package( INCLUDE_DIRS foxglove_bridge_base/include LIBRARIES foxglove_bridge_base foxglove_bridge_nodelet - CATKIN_DEPENDS nodelet ros_babel_fish rosgraph_msgs roslib roscpp + CATKIN_DEPENDS nodelet resource_retriever ros_babel_fish rosgraph_msgs roslib roscpp DEPENDS Boost ) @@ -135,6 +135,7 @@ elseif("$ENV{ROS_VERSION}" STREQUAL "2") find_package(rosgraph_msgs REQUIRED) find_package(rclcpp REQUIRED) find_package(rclcpp_components REQUIRED) + find_package(resource_retriever REQUIRED) add_library(foxglove_bridge_component SHARED ros2_foxglove_bridge/src/message_definition_cache.cpp @@ -149,7 +150,7 @@ elseif("$ENV{ROS_VERSION}" STREQUAL "2") $ $ ) - ament_target_dependencies(foxglove_bridge_component rclcpp rclcpp_components rosgraph_msgs) + ament_target_dependencies(foxglove_bridge_component rclcpp rclcpp_components resource_retriever rosgraph_msgs) target_link_libraries(foxglove_bridge_component foxglove_bridge_base) rclcpp_components_register_nodes(foxglove_bridge_component "foxglove_bridge::FoxgloveBridge") enable_strict_compiler_warnings(foxglove_bridge_component) @@ -258,6 +259,9 @@ elseif(ROS_BUILD_TYPE STREQUAL "ament_cmake") install(DIRECTORY ros2_foxglove_bridge/launch/ DESTINATION share/${PROJECT_NAME}/ ) + install(FILES ros2_foxglove_bridge/include/foxglove_bridge/utils.hpp + DESTINATION include/${PROJECT_NAME}/ + ) ament_export_libraries(foxglove_bridge_base foxglove_bridge_component) ament_package() endif() diff --git a/README.md b/README.md index ffabc69..e917e54 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ Parameters are provided to configure the behavior of the bridge. These parameter * __client_topic_whitelist__: List of regular expressions ([ECMAScript grammar](https://en.cppreference.com/w/cpp/regex/ecmascript)) of whitelisted client-published topic names. Defaults to `[".*"]`. * __send_buffer_limit__: Connection send buffer limit in bytes. Messages will be dropped when a connection's send buffer reaches this limit to avoid a queue of outdated messages building up. Defaults to `10000000` (10 MB). * __use_compression__: Use websocket compression (permessage-deflate). Suited for connections with smaller bandwith, at the cost of additional CPU load. - * __capabilities__: List of supported [server capabilities](https://github.com/foxglove/ws-protocol/blob/main/docs/spec.md). Defaults to `[clientPublish,parameters,parametersSubscribe,services,connectionGraph]`. + * __capabilities__: List of supported [server capabilities](https://github.com/foxglove/ws-protocol/blob/main/docs/spec.md). Defaults to `[clientPublish,parameters,parametersSubscribe,services,connectionGraph,assets]`. + * __asset_uri_whitelist__: List of regular expressions ([ECMAScript grammar](https://en.cppreference.com/w/cpp/regex/ecmascript)) of allowed asset URIs. Defaults to `["package://.*"]`. * (ROS 1) __max_update_ms__: The maximum number of milliseconds to wait in between polling `roscore` for new topics, services, or parameters. Defaults to `5000`. * (ROS 2) __num_threads__: The number of threads to use for the ROS node executor. This controls the number of subscriptions that can be processed in parallel. 0 means one thread per CPU core. Defaults to `0`. * (ROS 2) __min_qos_depth__: Minimum depth used for the QoS profile of subscriptions. Defaults to `1`. This is to set a lower limit for a subscriber's QoS depth which is computed by summing up depths of all publishers. See also [#208](https://github.com/foxglove/ros-foxglove-bridge/issues/208). diff --git a/foxglove_bridge_base/include/foxglove_bridge/common.hpp b/foxglove_bridge_base/include/foxglove_bridge/common.hpp index 90e1f2d..8019b40 100644 --- a/foxglove_bridge_base/include/foxglove_bridge/common.hpp +++ b/foxglove_bridge_base/include/foxglove_bridge/common.hpp @@ -16,10 +16,11 @@ constexpr char CAPABILITY_PARAMETERS[] = "parameters"; constexpr char CAPABILITY_PARAMETERS_SUBSCRIBE[] = "parametersSubscribe"; constexpr char CAPABILITY_SERVICES[] = "services"; constexpr char CAPABILITY_CONNECTION_GRAPH[] = "connectionGraph"; +constexpr char CAPABILITY_ASSETS[] = "assets"; -constexpr std::array DEFAULT_CAPABILITIES = { +constexpr std::array DEFAULT_CAPABILITIES = { CAPABILITY_CLIENT_PUBLISH, CAPABILITY_CONNECTION_GRAPH, CAPABILITY_PARAMETERS_SUBSCRIBE, - CAPABILITY_PARAMETERS, CAPABILITY_SERVICES, + CAPABILITY_PARAMETERS, CAPABILITY_SERVICES, CAPABILITY_ASSETS, }; using ChannelId = uint32_t; @@ -31,6 +32,7 @@ enum class BinaryOpcode : uint8_t { MESSAGE_DATA = 1, TIME_DATA = 2, SERVICE_CALL_RESPONSE = 3, + FETCH_ASSET_RESPONSE = 4, }; enum class ClientBinaryOpcode : uint8_t { @@ -145,4 +147,16 @@ struct ServiceResponse { using ServiceRequest = ServiceResponse; +enum FetchAssetStatus { + Success = 0, + Error = 1, +}; + +struct FetchAssetResponse { + uint32_t requestId; + FetchAssetStatus status; + std::string errorMessage; + std::vector data; +}; + } // namespace foxglove diff --git a/foxglove_bridge_base/include/foxglove_bridge/server_interface.hpp b/foxglove_bridge_base/include/foxglove_bridge/server_interface.hpp index f21931c..4c02caa 100644 --- a/foxglove_bridge_base/include/foxglove_bridge/server_interface.hpp +++ b/foxglove_bridge_base/include/foxglove_bridge/server_interface.hpp @@ -73,6 +73,7 @@ struct ServerHandlers { parameterSubscriptionHandler; std::function serviceRequestHandler; std::function subscribeConnectionGraphHandler; + std::function fetchAssetHandler; }; template @@ -101,6 +102,8 @@ class ServerInterface { virtual void updateConnectionGraph(const MapOfSets& publishedTopics, const MapOfSets& subscribedTopics, const MapOfSets& advertisedServices) = 0; + virtual void sendFetchAssetResponse(ConnectionHandle clientHandle, + const FetchAssetResponse& response) = 0; virtual uint16_t getPort() = 0; virtual std::string remoteEndpointString(ConnectionHandle clientHandle) = 0; diff --git a/foxglove_bridge_base/include/foxglove_bridge/test/test_client.hpp b/foxglove_bridge_base/include/foxglove_bridge/test/test_client.hpp index ff1483a..586328d 100644 --- a/foxglove_bridge_base/include/foxglove_bridge/test/test_client.hpp +++ b/foxglove_bridge_base/include/foxglove_bridge/test/test_client.hpp @@ -25,6 +25,8 @@ std::future waitForService(std::shared_ptr client, std::future waitForChannel(std::shared_ptr client, const std::string& topicName); +std::future waitForFetchAssetResponse(std::shared_ptr client); + extern template class Client; } // namespace foxglove diff --git a/foxglove_bridge_base/include/foxglove_bridge/websocket_client.hpp b/foxglove_bridge_base/include/foxglove_bridge/websocket_client.hpp index 02289bf..2595ff5 100644 --- a/foxglove_bridge_base/include/foxglove_bridge/websocket_client.hpp +++ b/foxglove_bridge_base/include/foxglove_bridge/websocket_client.hpp @@ -50,6 +50,7 @@ class ClientInterface { const std::optional& requestId) = 0; virtual void subscribeParameterUpdates(const std::vector& parameterNames) = 0; virtual void unsubscribeParameterUpdates(const std::vector& parameterNames) = 0; + virtual void fetchAsset(const std::string& name, uint32_t requestId) = 0; virtual void setTextMessageHandler(TextMessageHandler handler) = 0; virtual void setBinaryMessageHandler(BinaryMessageHandler handler) = 0; @@ -221,6 +222,11 @@ class Client : public ClientInterface { sendText(jsonPayload.dump()); } + void fetchAsset(const std::string& uri, uint32_t requestId) override { + nlohmann::json jsonPayload{{"op", "fetchAsset"}, {"uri", uri}, {"requestId", requestId}}; + sendText(jsonPayload.dump()); + } + void setTextMessageHandler(TextMessageHandler handler) override { std::unique_lock lock(_mutex); _textMessageHandler = std::move(handler); diff --git a/foxglove_bridge_base/include/foxglove_bridge/websocket_server.hpp b/foxglove_bridge_base/include/foxglove_bridge/websocket_server.hpp index d3e9607..4afd87b 100644 --- a/foxglove_bridge_base/include/foxglove_bridge/websocket_server.hpp +++ b/foxglove_bridge_base/include/foxglove_bridge/websocket_server.hpp @@ -69,6 +69,7 @@ const std::unordered_map CAPABILITY_BY_CLIENT_OPERATIO {"unsubscribeParameterUpdates", CAPABILITY_PARAMETERS_SUBSCRIBE}, {"subscribeConnectionGraph", CAPABILITY_CONNECTION_GRAPH}, {"unsubscribeConnectionGraph", CAPABILITY_CONNECTION_GRAPH}, + {"fetchAsset", CAPABILITY_ASSETS}, }; /// Map of required capability by client operation (binary). @@ -131,6 +132,7 @@ class Server final : public ServerInterface { void sendServiceResponse(ConnHandle clientHandle, const ServiceResponse& response) override; void updateConnectionGraph(const MapOfSets& publishedTopics, const MapOfSets& subscribedTopics, const MapOfSets& advertisedServices) override; + void sendFetchAssetResponse(ConnHandle clientHandle, const FetchAssetResponse& response) override; uint16_t getPort() override; std::string remoteEndpointString(ConnHandle clientHandle) override; @@ -622,6 +624,7 @@ inline void Server::handleTextMessage(ConnHandle hdl, Messa constexpr auto UNSUBSCRIBE_PARAMETER_UPDATES = Integer("unsubscribeParameterUpdates"); constexpr auto SUBSCRIBE_CONNECTION_GRAPH = Integer("subscribeConnectionGraph"); constexpr auto UNSUBSCRIBE_CONNECTION_GRAPH = Integer("unsubscribeConnectionGraph"); + constexpr auto FETCH_ASSET = Integer("fetchAsset"); switch (Integer(op)) { case SUBSCRIBE: { @@ -906,6 +909,22 @@ inline void Server::handleTextMessage(ConnHandle hdl, Messa "Client was not subscribed to connection graph updates"); } } break; + case FETCH_ASSET: { + if (!_handlers.fetchAssetHandler) { + return; + } + + const auto uri = payload.at("uri").get(); + const auto requestId = payload.at("requestId").get(); + + try { + _handlers.fetchAssetHandler(uri, requestId, hdl); + } catch (const std::exception& e) { + sendStatusAndLogMsg(hdl, StatusLevel::Error, e.what()); + } catch (...) { + sendStatusAndLogMsg(hdl, StatusLevel::Error, op + ": Failed to execute handler"); + } + } break; default: { sendStatusAndLogMsg(hdl, StatusLevel::Error, "Unrecognized client opcode \"" + op + "\""); } break; @@ -1371,4 +1390,36 @@ inline bool Server::hasCapability(const std::string& capabi _options.capabilities.end(); } +template +inline void Server::sendFetchAssetResponse( + ConnHandle clientHandle, const FetchAssetResponse& response) { + std::error_code ec; + const auto con = _server.get_con_from_hdl(clientHandle, ec); + if (ec || !con) { + return; + } + + const size_t dataSize = response.status == FetchAssetStatus::Success ? response.data.size() : 0ul; + const size_t messageSize = 1 + 4 + 1 + 4 + response.errorMessage.size() + dataSize; + + auto message = con->get_message(OpCode::BINARY, messageSize); + + const auto op = BinaryOpcode::FETCH_ASSET_RESPONSE; + message->append_payload(&op, 1); + + std::array uint32Data; + foxglove::WriteUint32LE(uint32Data.data(), response.requestId); + message->append_payload(uint32Data.data(), uint32Data.size()); + + const uint8_t status = static_cast(response.status); + message->append_payload(&status, 1); + + foxglove::WriteUint32LE(uint32Data.data(), response.errorMessage.size()); + message->append_payload(uint32Data.data(), uint32Data.size()); + message->append_payload(response.errorMessage); + + message->append_payload(response.data.data(), dataSize); + con->send(message); +} + } // namespace foxglove diff --git a/foxglove_bridge_base/src/test/test_client.cpp b/foxglove_bridge_base/src/test/test_client.cpp index 019f23d..89ba67d 100644 --- a/foxglove_bridge_base/src/test/test_client.cpp +++ b/foxglove_bridge_base/src/test/test_client.cpp @@ -132,7 +132,35 @@ std::future waitForChannel(std::shared_ptr client, } } }); + return future; +} + +std::future waitForFetchAssetResponse(std::shared_ptr client) { + auto promise = std::make_shared>(); + auto future = promise->get_future(); + client->setBinaryMessageHandler( + [promise = std::move(promise)](const uint8_t* data, size_t dataLength) mutable { + if (static_cast(data[0]) != BinaryOpcode::FETCH_ASSET_RESPONSE) { + return; + } + + foxglove::FetchAssetResponse response; + size_t offset = 1; + response.requestId = ReadUint32LE(data + offset); + offset += 4; + response.status = static_cast(data[offset]); + offset += 1; + const size_t errorMsgLength = static_cast(ReadUint32LE(data + offset)); + offset += 4; + response.errorMessage = + std::string(reinterpret_cast(data + offset), errorMsgLength); + offset += errorMsgLength; + const auto payloadLength = dataLength - offset; + response.data.resize(payloadLength); + std::memcpy(response.data.data(), data + offset, payloadLength); + promise->set_value(response); + }); return future; } diff --git a/package.xml b/package.xml index 7d73263..f3df803 100644 --- a/package.xml +++ b/package.xml @@ -43,6 +43,7 @@ openssl zlib + resource_retriever rosgraph_msgs diff --git a/ros1_foxglove_bridge/launch/foxglove_bridge.launch b/ros1_foxglove_bridge/launch/foxglove_bridge.launch index 6796514..3171d8b 100644 --- a/ros1_foxglove_bridge/launch/foxglove_bridge.launch +++ b/ros1_foxglove_bridge/launch/foxglove_bridge.launch @@ -12,7 +12,8 @@ - + + @@ -34,5 +35,6 @@ $(arg service_whitelist) $(arg client_topic_whitelist) $(arg capabilities) + $(arg asset_uri_whitelist) diff --git a/ros1_foxglove_bridge/src/ros1_foxglove_bridge_nodelet.cpp b/ros1_foxglove_bridge/src/ros1_foxglove_bridge_nodelet.cpp index 35c16ca..fe5ab5a 100644 --- a/ros1_foxglove_bridge/src/ros1_foxglove_bridge_nodelet.cpp +++ b/ros1_foxglove_bridge/src/ros1_foxglove_bridge_nodelet.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -100,6 +101,13 @@ class FoxgloveBridge : public nodelet::Nodelet { ROS_ERROR("Failed to parse one or more service whitelist patterns"); } + const auto assetUriWhitelist = + nhp.param>("asset_uri_whitelist", {"package://.*", "file://.*"}); + _assetUriWhitelistPatterns = parseRegexPatterns(assetUriWhitelist); + if (assetUriWhitelist.size() != _assetUriWhitelistPatterns.size()) { + ROS_ERROR("Failed to parse one or more asset URI whitelist patterns"); + } + const char* rosDistro = std::getenv("ROS_DISTRO"); ROS_INFO("Starting foxglove_bridge (%s, %s@%s) with %s", rosDistro, foxglove::FOXGLOVE_BRIDGE_VERSION, foxglove::FOXGLOVE_BRIDGE_GIT_HASH, @@ -151,6 +159,13 @@ class FoxgloveBridge : public nodelet::Nodelet { hdlrs.subscribeConnectionGraphHandler = [this](bool subscribe) { _subscribeGraphUpdates = subscribe; }; + + if (hasCapability(foxglove::CAPABILITY_ASSETS)) { + hdlrs.fetchAssetHandler = + std::bind(&FoxgloveBridge::fetchAsset, this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3); + } + _server->setHandlers(std::move(hdlrs)); _server->start(address, static_cast(port)); @@ -844,6 +859,29 @@ class FoxgloveBridge : public nodelet::Nodelet { } } + void fetchAsset(const std::string& uri, uint32_t requestId, ConnectionHandle clientHandle) { + foxglove::FetchAssetResponse response; + response.requestId = requestId; + + try { + if (!isWhitelisted(uri, _assetUriWhitelistPatterns)) { + throw std::runtime_error("Asset URI not allowed: " + uri); + } + + const resource_retriever::MemoryResource memoryResource = _resource_retriever.get(uri); + response.status = foxglove::FetchAssetStatus::Success; + response.errorMessage = ""; + response.data.resize(memoryResource.size); + std::memcpy(response.data.data(), memoryResource.data.get(), memoryResource.size); + } catch (const resource_retriever::Exception& ex) { + ROS_WARN("Failed to retrieve asset '%s': %s", uri.c_str(), ex.what()); + response.status = foxglove::FetchAssetStatus::Error; + response.errorMessage = "Failed to retrieve asset " + uri; + } + + _server->sendFetchAssetResponse(clientHandle, response); + } + bool hasCapability(const std::string& capability) { return std::find(_capabilities.begin(), _capabilities.end(), capability) != _capabilities.end(); } @@ -853,6 +891,7 @@ class FoxgloveBridge : public nodelet::Nodelet { std::vector _topicWhitelistPatterns; std::vector _paramWhitelistPatterns; std::vector _serviceWhitelistPatterns; + std::vector _assetUriWhitelistPatterns; ros::XMLRPCManager xmlrpcServer; std::unordered_map _advertisedTopics; std::unordered_map _subscriptions; @@ -868,6 +907,7 @@ class FoxgloveBridge : public nodelet::Nodelet { bool _useSimTime = false; std::vector _capabilities; std::atomic _subscribeGraphUpdates = false; + resource_retriever::Retriever _resource_retriever; }; } // namespace foxglove_bridge diff --git a/ros1_foxglove_bridge/tests/smoke.test b/ros1_foxglove_bridge/tests/smoke.test index c84a448..b530b12 100644 --- a/ros1_foxglove_bridge/tests/smoke.test +++ b/ros1_foxglove_bridge/tests/smoke.test @@ -1,6 +1,7 @@ + ['file://.*'] diff --git a/ros1_foxglove_bridge/tests/smoke_test.cpp b/ros1_foxglove_bridge/tests/smoke_test.cpp index 2d3942d..d385890 100644 --- a/ros1_foxglove_bridge/tests/smoke_test.cpp +++ b/ros1_foxglove_bridge/tests/smoke_test.cpp @@ -345,6 +345,49 @@ TEST_F(ServiceTest, testCallServiceParallel) { } } +TEST(FetchAssetTest, fetchExistingAsset) { + auto wsClient = std::make_shared>(); + EXPECT_EQ(std::future_status::ready, wsClient->connect(URI).wait_for(DEFAULT_TIMEOUT)); + + const auto tmpFilePath = std::tmpnam(nullptr) + std::string(".txt"); + constexpr char content[] = "Hello, world"; + FILE* tmpAssetFile = std::fopen(tmpFilePath.c_str(), "w"); + std::fputs(content, tmpAssetFile); + std::fclose(tmpAssetFile); + + const std::string uri = std::string("file://") + tmpFilePath; + const uint32_t requestId = 123; + + auto future = foxglove::waitForFetchAssetResponse(wsClient); + wsClient->fetchAsset(uri, requestId); + ASSERT_EQ(std::future_status::ready, future.wait_for(DEFAULT_TIMEOUT)); + const foxglove::FetchAssetResponse response = future.get(); + + EXPECT_EQ(response.requestId, requestId); + EXPECT_EQ(response.status, foxglove::FetchAssetStatus::Success); + // +1 since NULL terminator is not written to file. + ASSERT_EQ(response.data.size() + 1ul, sizeof(content)); + EXPECT_EQ(0, std::memcmp(content, response.data.data(), response.data.size())); + std::remove(tmpFilePath.c_str()); +} + +TEST(FetchAssetTest, fetchNonExistingAsset) { + auto wsClient = std::make_shared>(); + EXPECT_EQ(std::future_status::ready, wsClient->connect(URI).wait_for(DEFAULT_TIMEOUT)); + + const std::string assetId = "file:///foo/bar"; + const uint32_t requestId = 456; + + auto future = foxglove::waitForFetchAssetResponse(wsClient); + wsClient->fetchAsset(assetId, requestId); + ASSERT_EQ(std::future_status::ready, future.wait_for(DEFAULT_TIMEOUT)); + const foxglove::FetchAssetResponse response = future.get(); + + EXPECT_EQ(response.requestId, requestId); + EXPECT_EQ(response.status, foxglove::FetchAssetStatus::Error); + EXPECT_FALSE(response.errorMessage.empty()); +} + // Run all the tests that were declared with TEST() int main(int argc, char** argv) { testing::InitGoogleTest(&argc, argv); diff --git a/ros2_foxglove_bridge/include/foxglove_bridge/param_utils.hpp b/ros2_foxglove_bridge/include/foxglove_bridge/param_utils.hpp index 8a2f45d..d34a775 100644 --- a/ros2_foxglove_bridge/include/foxglove_bridge/param_utils.hpp +++ b/ros2_foxglove_bridge/include/foxglove_bridge/param_utils.hpp @@ -23,6 +23,7 @@ constexpr char PARAM_USE_COMPRESSION[] = "use_compression"; constexpr char PARAM_CAPABILITIES[] = "capabilities"; constexpr char PARAM_CLIENT_TOPIC_WHITELIST[] = "client_topic_whitelist"; constexpr char PARAM_INCLUDE_HIDDEN[] = "include_hidden"; +constexpr char PARAM_ASSET_URI_WHITELIST[] = "asset_uri_whitelist"; constexpr int64_t DEFAULT_PORT = 8765; constexpr char DEFAULT_ADDRESS[] = "0.0.0.0"; diff --git a/ros2_foxglove_bridge/include/foxglove_bridge/ros2_foxglove_bridge.hpp b/ros2_foxglove_bridge/include/foxglove_bridge/ros2_foxglove_bridge.hpp index a11e4fc..f406be9 100644 --- a/ros2_foxglove_bridge/include/foxglove_bridge/ros2_foxglove_bridge.hpp +++ b/ros2_foxglove_bridge/include/foxglove_bridge/ros2_foxglove_bridge.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -59,6 +60,7 @@ class FoxgloveBridge : public rclcpp::Node { foxglove::MessageDefinitionCache _messageDefinitionCache; std::vector _topicWhitelistPatterns; std::vector _serviceWhitelistPatterns; + std::vector _assetUriWhitelistPatterns; std::shared_ptr _paramInterface; std::unordered_map _advertisedTopics; std::unordered_map _advertisedServices; @@ -79,6 +81,7 @@ class FoxgloveBridge : public rclcpp::Node { std::vector _capabilities; std::atomic _subscribeGraphUpdates = false; bool _includeHidden = false; + resource_retriever::Retriever _resource_retriever; void subscribeConnectionGraph(bool subscribe); @@ -110,6 +113,8 @@ class FoxgloveBridge : public rclcpp::Node { void serviceRequest(const foxglove::ServiceRequest& request, ConnectionHandle clientHandle); + void fetchAsset(const std::string& assetId, uint32_t requestId, ConnectionHandle clientHandle); + bool hasCapability(const std::string& capability); }; diff --git a/ros2_foxglove_bridge/launch/foxglove_bridge_launch.xml b/ros2_foxglove_bridge/launch/foxglove_bridge_launch.xml index 576dbe2..e12d9f9 100644 --- a/ros2_foxglove_bridge/launch/foxglove_bridge_launch.xml +++ b/ros2_foxglove_bridge/launch/foxglove_bridge_launch.xml @@ -13,8 +13,9 @@ - + + @@ -33,5 +34,6 @@ + diff --git a/ros2_foxglove_bridge/src/param_utils.cpp b/ros2_foxglove_bridge/src/param_utils.cpp index a8d5dcd..55bb3e7 100644 --- a/ros2_foxglove_bridge/src/param_utils.cpp +++ b/ros2_foxglove_bridge/src/param_utils.cpp @@ -146,6 +146,15 @@ void declareParameters(rclcpp::Node* node) { includeHiddenDescription.description = "Include hidden topics and services"; includeHiddenDescription.read_only = true; node->declare_parameter(PARAM_INCLUDE_HIDDEN, false, includeHiddenDescription); + + auto assetUriWhiteListDescription = rcl_interfaces::msg::ParameterDescriptor{}; + assetUriWhiteListDescription.name = PARAM_ASSET_URI_WHITELIST; + assetUriWhiteListDescription.type = rcl_interfaces::msg::ParameterType::PARAMETER_STRING_ARRAY; + assetUriWhiteListDescription.description = + "List of regular expressions (ECMAScript) of whitelisted asset URIs."; + assetUriWhiteListDescription.read_only = true; + node->declare_parameter(PARAM_ASSET_URI_WHITELIST, std::vector({"package://.*"}), + paramWhiteListDescription); } std::vector parseRegexStrings(rclcpp::Node* node, diff --git a/ros2_foxglove_bridge/src/ros2_foxglove_bridge.cpp b/ros2_foxglove_bridge/src/ros2_foxglove_bridge.cpp index 4a65b0e..a6e2eee 100644 --- a/ros2_foxglove_bridge/src/ros2_foxglove_bridge.cpp +++ b/ros2_foxglove_bridge/src/ros2_foxglove_bridge.cpp @@ -47,6 +47,8 @@ FoxgloveBridge::FoxgloveBridge(const rclcpp::NodeOptions& options) this->get_parameter(PARAM_CLIENT_TOPIC_WHITELIST).as_string_array(); const auto clientTopicWhiteListPatterns = parseRegexStrings(this, clientTopicWhiteList); _includeHidden = this->get_parameter(PARAM_INCLUDE_HIDDEN).as_bool(); + const auto assetUriWhitelist = this->get_parameter(PARAM_ASSET_URI_WHITELIST).as_string_array(); + _assetUriWhitelistPatterns = parseRegexStrings(this, assetUriWhitelist); const auto logHandler = std::bind(&FoxgloveBridge::logHandler, this, _1, _2); foxglove::ServerOptions serverOptions; @@ -88,6 +90,10 @@ FoxgloveBridge::FoxgloveBridge(const rclcpp::NodeOptions& options) _paramInterface->setParamUpdateCallback(std::bind(&FoxgloveBridge::parameterUpdates, this, _1)); } + if (hasCapability(foxglove::CAPABILITY_ASSETS)) { + hdlrs.fetchAssetHandler = std::bind(&FoxgloveBridge::fetchAsset, this, _1, _2, _3); + } + _server->setHandlers(std::move(hdlrs)); _server->start(address, port); @@ -814,6 +820,30 @@ void FoxgloveBridge::serviceRequest(const foxglove::ServiceRequest& request, client->async_send_request(reqMessage, responseReceivedCallback); } +void FoxgloveBridge::fetchAsset(const std::string& uri, uint32_t requestId, + ConnectionHandle clientHandle) { + foxglove::FetchAssetResponse response; + response.requestId = requestId; + + try { + if (!isWhitelisted(uri, _assetUriWhitelistPatterns)) { + throw std::runtime_error("Asset URI not allowed: " + uri); + } + + const resource_retriever::MemoryResource memoryResource = _resource_retriever.get(uri); + response.status = foxglove::FetchAssetStatus::Success; + response.errorMessage = ""; + response.data.resize(memoryResource.size); + std::memcpy(response.data.data(), memoryResource.data.get(), memoryResource.size); + } catch (const resource_retriever::Exception& ex) { + RCLCPP_WARN(this->get_logger(), "Failed to retrieve asset '%s': %s", uri.c_str(), ex.what()); + response.status = foxglove::FetchAssetStatus::Error; + response.errorMessage = "Failed to retrieve asset " + uri; + } + + _server->sendFetchAssetResponse(clientHandle, response); +} + bool FoxgloveBridge::hasCapability(const std::string& capability) { return std::find(_capabilities.begin(), _capabilities.end(), capability) != _capabilities.end(); } diff --git a/ros2_foxglove_bridge/tests/smoke_test.cpp b/ros2_foxglove_bridge/tests/smoke_test.cpp index 19624e4..fab35a9 100644 --- a/ros2_foxglove_bridge/tests/smoke_test.cpp +++ b/ros2_foxglove_bridge/tests/smoke_test.cpp @@ -543,6 +543,49 @@ TEST(SmokeTest, receiveMessagesOfMultipleTransientLocalPublishers) { spinnerThread.join(); } +TEST(FetchAssetTest, fetchExistingAsset) { + auto wsClient = std::make_shared>(); + EXPECT_EQ(std::future_status::ready, wsClient->connect(URI).wait_for(DEFAULT_TIMEOUT)); + + const auto tmpFilePath = std::tmpnam(nullptr) + std::string(".txt"); + constexpr char content[] = "Hello, world"; + FILE* tmpAssetFile = std::fopen(tmpFilePath.c_str(), "w"); + std::fputs(content, tmpAssetFile); + std::fclose(tmpAssetFile); + + const std::string uri = std::string("file://") + tmpFilePath; + const uint32_t requestId = 123; + + auto future = foxglove::waitForFetchAssetResponse(wsClient); + wsClient->fetchAsset(uri, requestId); + ASSERT_EQ(std::future_status::ready, future.wait_for(DEFAULT_TIMEOUT)); + const foxglove::FetchAssetResponse response = future.get(); + + EXPECT_EQ(response.requestId, requestId); + EXPECT_EQ(response.status, foxglove::FetchAssetStatus::Success); + // +1 since NULL terminator is not written to file. + ASSERT_EQ(response.data.size() + 1ul, sizeof(content)); + EXPECT_EQ(0, std::memcmp(content, response.data.data(), response.data.size())); + std::remove(tmpFilePath.c_str()); +} + +TEST(FetchAssetTest, fetchNonExistingAsset) { + auto wsClient = std::make_shared>(); + EXPECT_EQ(std::future_status::ready, wsClient->connect(URI).wait_for(DEFAULT_TIMEOUT)); + + const std::string assetId = "file:///foo/bar"; + const uint32_t requestId = 456; + + auto future = foxglove::waitForFetchAssetResponse(wsClient); + wsClient->fetchAsset(assetId, requestId); + ASSERT_EQ(std::future_status::ready, future.wait_for(DEFAULT_TIMEOUT)); + const foxglove::FetchAssetResponse response = future.get(); + + EXPECT_EQ(response.requestId, requestId); + EXPECT_EQ(response.status, foxglove::FetchAssetStatus::Error); + EXPECT_FALSE(response.errorMessage.empty()); +} + // Run all the tests that were declared with TEST() int main(int argc, char** argv) { testing::InitGoogleTest(&argc, argv); @@ -561,7 +604,11 @@ int main(int argc, char** argv) { } auto componentFactory = componentManager.create_component_factory(componentResources.front()); - auto node = componentFactory->create_node_instance(rclcpp::NodeOptions()); + rclcpp::NodeOptions nodeOptions; + // Explicitly allow file:// asset URIs for testing purposes. + nodeOptions.append_parameter_override("asset_uri_whitelist", + std::vector({"file://.*"})); + auto node = componentFactory->create_node_instance(nodeOptions); executor->add_node(node.get_node_base_interface()); std::thread spinnerThread([&executor]() {