diff --git a/CHANGELOG.md b/CHANGELOG.md index ee15478d..b9710af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [0.23.0] + +### Fix: entry-point symbol provenance and modern platform loaders (MINOR) + +Plugin admission now proves that the ABI marker and family vtable getter are +defined by the candidate DSO itself instead of accepting definitions from a +dependency. POSIX uses defining-object identity, macOS restricts handle-scoped +lookups to the first image, and Windows uses filesystem-native wide paths with +package-scoped dependency search and defining-module checks that reject +forwarded PE exports. Recorded normalized absolute load paths and their +best-effort symlink-resolved forms let deferred dialog-vtable provenance accept +either defining-path spelling without re-stating the candidate, so staged +deletion, later working-directory changes, and macOS dyld realpath reporting +are safe. Plugin install rpaths now resolve bundled dependencies relative to +the plugin on Linux and macOS. + +Native functional parser modules now share the same absolute-path open and +symbol-provenance checks, including `RTLD_FIRST` on macOS. Their narrow load +API retains its explicit UTF-8 contract on Windows and rejects invalid UTF-8 +before calling the platform loader. + +New filesystem-path overloads and already-open-handle adoption APIs let hosts +validate, inspect, and instantiate a candidate through one native module open. +Static initializers now run once per admission instead of up to three times. + +There is no C-ABI or protocol change: `PJ_ABI_VERSION`, all family protocol +versions, vtable layouts, and `abi/baseline.abi` remain unchanged. The release +is MINOR because the installed C++ host API gains additive overloads. + ## [0.22.0] ### Feature: extensible parser routing and functional parser modules (MINOR) diff --git a/VERSION b/VERSION index 21574090..ca222b7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.0 +0.23.0 diff --git a/cmake/PjPluginManifest.cmake b/cmake/PjPluginManifest.cmake index 34670c57..0ffcd5ca 100644 --- a/cmake/PjPluginManifest.cmake +++ b/cmake/PjPluginManifest.cmake @@ -61,6 +61,16 @@ function(pj_emit_plugin_manifest TARGET) C_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON ) + if(APPLE) + set_target_properties(${TARGET} PROPERTIES + INSTALL_RPATH "@loader_path" + MACOSX_RPATH ON + ) + elseif(UNIX) + set_target_properties(${TARGET} PROPERTIES + INSTALL_RPATH "$ORIGIN" + ) + endif() target_link_options(${TARGET} PRIVATE $<$:-Wl,-Bsymbolic-functions> ) diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index 2144d4c7..b2bb9758 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -343,6 +343,90 @@ target_compile_features(legacy_macro_dialog_plugin PRIVATE cxx_std_20) target_compile_options(legacy_macro_dialog_plugin PRIVATE ${PJ_WARNING_FLAGS}) target_link_libraries(legacy_macro_dialog_plugin PRIVATE pj_dialog_protocol) +# First two-DSO loader fixtures: one malformed candidate whose entry points +# come only from a dependency, and one well-formed candidate that defines its +# own entry points while retaining the same dependency. +add_library(entry_point_donor SHARED tests/entry_point_donor.cpp) +target_compile_features(entry_point_donor PRIVATE cxx_std_20) +target_compile_options(entry_point_donor PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(entry_point_donor PRIVATE pj_base) +# The PE forwarder pragma in entry_point_forwarder.cpp names entry_point_donor.dll +# at runtime. Pin the output basename so it cannot drift from that module token. +set_target_properties(entry_point_donor PROPERTIES OUTPUT_NAME entry_point_donor) + +add_library(entry_point_via_dependency_plugin SHARED tests/entry_point_via_dependency.cpp) +target_compile_features(entry_point_via_dependency_plugin PRIVATE cxx_std_20) +target_compile_options(entry_point_via_dependency_plugin PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(entry_point_via_dependency_plugin PRIVATE entry_point_donor pj_base) + +add_library(entry_point_with_own_exports_plugin SHARED tests/entry_point_with_own_exports.cpp) +target_compile_features(entry_point_with_own_exports_plugin PRIVATE cxx_std_20) +target_compile_options(entry_point_with_own_exports_plugin PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(entry_point_with_own_exports_plugin PRIVATE entry_point_donor pj_base) + +if(WIN32) + add_library(entry_point_forwarder_plugin SHARED + tests/entry_point_forwarder.cpp + ) + target_compile_features(entry_point_forwarder_plugin PRIVATE cxx_std_20) + target_compile_options(entry_point_forwarder_plugin PRIVATE ${PJ_WARNING_FLAGS}) + # Deliberately do not link entry_point_donor: the /export pragma in the source + # emits a PE forwarder, not an import the linker should resolve. Both targets + # share one runtime output directory, so entry_point_donor.dll is available + # when GetProcAddress follows the forwarder. + target_link_libraries(entry_point_forwarder_plugin PRIVATE pj_base) + add_dependencies(entry_point_forwarder_plugin entry_point_donor) + set_target_properties( + entry_point_donor + entry_point_via_dependency_plugin + entry_point_with_own_exports_plugin + entry_point_forwarder_plugin + PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/entry_point_fixtures" + ) +endif() + +# Dependency-search fixtures. The real and decoy dependencies intentionally +# share one filename but live in separate build directories. +add_library(dependency_search_real SHARED tests/dependency_search_dependency.cpp) +target_compile_features(dependency_search_real PRIVATE cxx_std_20) +target_compile_options(dependency_search_real PRIVATE ${PJ_WARNING_FLAGS}) +set_target_properties(dependency_search_real PROPERTIES + OUTPUT_NAME pj_dependency_search_fixture + # Keep this DLL out of the test executable's directory: on Windows the + # loader's LOAD_LIBRARY_SEARCH_APPLICATION_DIR leg would otherwise resolve + # the dependency from there and defeat the decoy-only case. + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dependency_search_real" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dependency_search_real" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dependency_search_real" +) + +add_library(dependency_search_decoy SHARED tests/dependency_search_dependency.cpp) +target_compile_features(dependency_search_decoy PRIVATE cxx_std_20) +target_compile_options(dependency_search_decoy PRIVATE ${PJ_WARNING_FLAGS}) +target_compile_definitions(dependency_search_decoy PRIVATE PJ_DEPENDENCY_SEARCH_DECOY) +set_target_properties(dependency_search_decoy PROPERTIES + OUTPUT_NAME pj_dependency_search_fixture + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dependency_search_decoy" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dependency_search_decoy" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dependency_search_decoy" +) + +add_library(dependency_search_candidate_plugin SHARED tests/dependency_search_candidate.cpp) +target_compile_features(dependency_search_candidate_plugin PRIVATE cxx_std_20) +target_compile_options(dependency_search_candidate_plugin PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(dependency_search_candidate_plugin PRIVATE dependency_search_real pj_base) +if(APPLE) + # Keep this copied fixture package-relative without CMake appending the + # absolute build directory, which would invalidate the decoy-only case. + set_target_properties(dependency_search_candidate_plugin PROPERTIES SKIP_BUILD_RPATH TRUE) + target_link_options(dependency_search_candidate_plugin PRIVATE "LINKER:-rpath,@loader_path") +elseif(UNIX) + set_target_properties(dependency_search_candidate_plugin PROPERTIES + BUILD_RPATH "$ORIGIN" + BUILD_RPATH_USE_ORIGIN TRUE + ) +endif() + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -367,6 +451,7 @@ target_compile_definitions(source_dialog_integration_test PRIVATE PJ_MOCK_DATA_SOURCE_PLUGIN_PATH="$" ) target_compile_options(source_dialog_integration_test PRIVATE ${PJ_WARNING_FLAGS}) +target_include_directories(source_dialog_integration_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(source_dialog_integration_test PRIVATE pj_data_source_host pj_dialog_host pj_base GTest::gtest_main ) @@ -565,17 +650,33 @@ target_compile_definitions(plugin_catalog_test PRIVATE PJ_MISSING_ID_PLUGIN_PATH="$" PJ_INVALID_OPTIONAL_PLUGIN_PATH="$" PJ_MISSING_REQUIRED_SLOTS_PLUGIN_PATH="$" -) + PJ_ENTRY_POINT_VIA_DEPENDENCY_PLUGIN_PATH="$" + PJ_ENTRY_POINT_WITH_OWN_EXPORTS_PLUGIN_PATH="$" + PJ_DEPENDENCY_SEARCH_CANDIDATE_PATH="$" + PJ_DEPENDENCY_SEARCH_REAL_PATH="$" + PJ_DEPENDENCY_SEARCH_DECOY_PATH="$" +) +if(WIN32) + target_compile_definitions(plugin_catalog_test PRIVATE + PJ_ENTRY_POINT_FORWARDER_PLUGIN_PATH="$" + ) +endif() target_compile_options(plugin_catalog_test PRIVATE ${PJ_WARNING_FLAGS}) +target_include_directories(plugin_catalog_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(plugin_catalog_test PRIVATE - pj_plugin_catalog GTest::gtest_main + pj_plugin_catalog pj_data_source_host GTest::gtest_main ) add_dependencies(plugin_catalog_test mock_data_source_plugin mock_json_parser_plugin mock_toolbox_plugin mock_dialog_plugin missing_id_data_source_plugin invalid_optional_manifest_data_source_plugin missing_required_slots_plugin static_manifest_dialog_plugin legacy_macro_dialog_plugin - old_dialog_vtable_plugin missing_dialog_required_slots_plugin) + old_dialog_vtable_plugin missing_dialog_required_slots_plugin + entry_point_via_dependency_plugin entry_point_with_own_exports_plugin + dependency_search_candidate_plugin dependency_search_decoy) +if(WIN32) + add_dependencies(plugin_catalog_test entry_point_forwarder_plugin) +endif() add_test(NAME plugin_catalog_test COMMAND plugin_catalog_test) endif() # PJ_BUILD_TESTS diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_library.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_library.hpp index 781c8832..a265ccb4 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_library.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_library.hpp @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -32,6 +33,25 @@ class DialogLibrary { /// Load a dialog plugin from @p path. Returns an error string on failure. [[nodiscard]] static Expected load(std::string_view path); + /// Preserve an unambiguous load call for existing narrow string paths. + [[nodiscard]] static Expected load(const char* path) { + return load(std::string_view(path)); + } + + /// Preserve an unambiguous load call for existing `std::string` paths. + [[nodiscard]] static Expected load(const std::string& path) { + return load(std::string_view(path)); + } + + /// Load a dialog plugin from a filesystem-native @p path. + [[nodiscard]] static Expected load(const std::filesystem::path& path); + + /// Validate and retain an already-open @p handle whose file is @p origin. + /// The library shares the caller-supplied handle ownership and does not open + /// or close a separate native module during validation. + [[nodiscard]] static Expected loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin); + /// True if the library was loaded and the vtable resolved successfully. [[nodiscard]] bool valid() const { return handle_ != nullptr && vtable_ != nullptr; diff --git a/pj_plugins/dialog_protocol/src/dialog_library.cpp b/pj_plugins/dialog_protocol/src/dialog_library.cpp index 24987a37..cf857598 100644 --- a/pj_plugins/dialog_protocol/src/dialog_library.cpp +++ b/pj_plugins/dialog_protocol/src/dialog_library.cpp @@ -34,17 +34,32 @@ DialogLibrary& DialogLibrary::operator=(DialogLibrary&& other) noexcept { } Expected DialogLibrary::load(std::string_view path) { - auto raw_handle = detail::loadLibraryHandle(path); + return load(std::filesystem::path(path)); +} + +Expected DialogLibrary::load(const std::filesystem::path& path) { + detail::LibraryPathIdentity recorded_path; + auto raw_handle = detail::loadLibraryHandle(path, &recorded_path); if (!raw_handle) { return unexpected(raw_handle.error()); } - auto handle = detail::adoptLibraryHandle(*raw_handle); + return loadFromHandle(detail::adoptLibraryHandle(*raw_handle), recorded_path.load_path); +} - if (auto abi = detail::checkPluginAbiVersion(handle.get()); !abi) { +Expected DialogLibrary::loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin) { + if (handle == nullptr) { + return unexpected("library not loaded"); + } + auto recorded_path = detail::recordLibraryPathIdentity(origin); + if (!recorded_path) { + return unexpected(recorded_path.error()); + } + if (auto abi = detail::checkPluginAbiVersion(handle.get(), *recorded_path); !abi) { return unexpected(abi.error()); } - auto sym = detail::resolveSymbol(handle.get(), "PJ_get_dialog_vtable"); + auto sym = detail::resolveSymbol(handle.get(), "PJ_get_dialog_vtable", *recorded_path); if (!sym) { return unexpected(sym.error()); } @@ -64,7 +79,7 @@ Expected DialogLibrary::load(std::string_view path) { return unexpected(status.error()); } - return DialogLibrary(std::move(handle), vtable, std::string(path)); + return DialogLibrary(std::move(handle), vtable, detail::pathForLegacyAccessor(recorded_path->load_path)); } void DialogLibrary::reset() { diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 053bdb1c..696f56f0 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -215,6 +215,10 @@ previously-circulated pre-v4 design included): - **No more RTLD_DEEPBIND.** The loader uses `RTLD_NOW | RTLD_LOCAL` only (DEEPBIND was a documented ASAN/allocator-interposition trap). Plugin-local symbol isolation is left to `-fvisibility=hidden`. +- **Declined loader alternatives.** Admission does not use + `RTLD_NODELETE` as its lifetime contract, `RTLD_DEEPBIND`, `dlmopen`, or a + manifest-format change. Shared handle ownership controls lifetime, while + candidate-file provenance is checked directly at each boot-level symbol. Structural shape inherited from the pre-v4 design work (carries the service registry, error out-params, and typed borrowed-dialog patterns): @@ -448,8 +452,18 @@ These live in `sdk/detail/*_trampolines.hpp`. ## 5. Host Loaders Each family has a loader that: -1. Calls `dlopen` (or `LoadLibrary` on Windows) on the `.so` path. -2. Calls `dlsym` for the entry point symbol. +1. Lexically normalizes the candidate to an absolute filesystem path, passes + that exact path to `dlopen` (or `LoadLibraryExW` on Windows), and records + both that spelling and its best-effort `weakly_canonical()` spelling in the + library object for later symbol resolution. +2. Resolves the ABI marker and entry point, then verifies that each symbol's + defining object is the candidate DSO itself rather than a dependency. On + POSIX, an exact byte match between `dladdr().dli_fname` and either recorded + path succeeds without re-reading the filesystem; `equivalent()` is only the + fallback for genuinely different path spellings. On Windows, the defining + `HMODULE` is recovered from the resolved address with + `GetModuleHandleExW(... FROM_ADDRESS ...)` and compared to the candidate + handle, which also rejects forwarded PE exports. 3. Validates `protocol_version` and `struct_size`. 4. Stores the vtable pointer for creating handles. @@ -462,7 +476,13 @@ Each family has a loader that: Loaders also provide `resolveDialogVtable()` to find the dialog vtable in a plugin `.so` that exports both a family vtable and a dialog vtable (e.g. a -DataSource with an embedded dialog). +DataSource with an embedded dialog). These deferred lookups use the recorded +load-time paths, so they remain valid after the candidate file is removed, the +process working directory changes, or dyld reports a symlink-resolved filename. + +Native functional parser modules use the same absolute-path normalization, +package-scoped platform open, and defining-module provenance checks for every +required ABI export. Their narrow path API is explicitly UTF-8 on Windows. ### 5.1 Host-side diagnostic propagation diff --git a/pj_plugins/include/pj_plugins/host/data_source_library.hpp b/pj_plugins/include/pj_plugins/host/data_source_library.hpp index 4b55ff0a..817ebf3c 100644 --- a/pj_plugins/include/pj_plugins/host/data_source_library.hpp +++ b/pj_plugins/include/pj_plugins/host/data_source_library.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -30,6 +31,10 @@ namespace PJ { +namespace detail { +struct LibraryPathIdentity; +} + /** * Loads a DataSource plugin shared library and provides factory access. * @@ -51,6 +56,25 @@ class DataSourceLibrary { /// Load a plugin from @p path. Returns an error string on failure. [[nodiscard]] static Expected load(std::string_view path); + /// Preserve an unambiguous load call for existing narrow string paths. + [[nodiscard]] static Expected load(const char* path) { + return load(std::string_view(path)); + } + + /// Preserve an unambiguous load call for existing `std::string` paths. + [[nodiscard]] static Expected load(const std::string& path) { + return load(std::string_view(path)); + } + + /// Load a plugin from a filesystem-native @p path. + [[nodiscard]] static Expected load(const std::filesystem::path& path); + + /// Validate and retain an already-open @p handle whose file is @p origin. + /// The library shares the caller-supplied handle ownership and does not open + /// or close a separate native module during validation. + [[nodiscard]] static Expected loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin); + /// Wrap a statically-linked plugin vtable (no dlopen; for WASM/static builds). /// @p vtable must have static storage duration (valid for the program lifetime). [[nodiscard]] static Expected loadStatic( @@ -80,8 +104,11 @@ class DataSourceLibrary { } private: + [[nodiscard]] static Expected loadFromHandleWithIdentity( + std::shared_ptr handle, const detail::LibraryPathIdentity& origin); + DataSourceLibrary( - std::shared_ptr handle, const PJ_data_source_vtable_t* vtable, std::string path, + std::shared_ptr handle, const PJ_data_source_vtable_t* vtable, std::string path, std::string resolved_path, const PJ_dialog_vtable_t* static_dialog_vtable = nullptr); void reset(); @@ -90,6 +117,7 @@ class DataSourceLibrary { const PJ_data_source_vtable_t* vtable_ = nullptr; const PJ_dialog_vtable_t* static_dialog_vtable_ = nullptr; std::string path_; + std::string resolved_path_; }; } // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/message_parser_library.hpp b/pj_plugins/include/pj_plugins/host/message_parser_library.hpp index 35300f87..c20e15d3 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_library.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_library.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -30,6 +31,10 @@ namespace PJ { +namespace detail { +struct LibraryPathIdentity; +} + /** * Loads a MessageParser plugin shared library and provides factory access. * @@ -51,6 +56,25 @@ class MessageParserLibrary { /// Load a plugin from @p path. Returns an error string on failure. [[nodiscard]] static Expected load(std::string_view path); + /// Preserve an unambiguous load call for existing narrow string paths. + [[nodiscard]] static Expected load(const char* path) { + return load(std::string_view(path)); + } + + /// Preserve an unambiguous load call for existing `std::string` paths. + [[nodiscard]] static Expected load(const std::string& path) { + return load(std::string_view(path)); + } + + /// Load a plugin from a filesystem-native @p path. + [[nodiscard]] static Expected load(const std::filesystem::path& path); + + /// Validate and retain an already-open @p handle whose file is @p origin. + /// The library shares the caller-supplied handle ownership and does not open + /// or close a separate native module during validation. + [[nodiscard]] static Expected loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin); + /// Wrap a statically-linked plugin vtable (no dlopen; for WASM/static builds). /// @p vtable must have static storage duration (valid for the program lifetime). [[nodiscard]] static Expected loadStatic( @@ -80,9 +104,12 @@ class MessageParserLibrary { } private: + [[nodiscard]] static Expected loadFromHandleWithIdentity( + std::shared_ptr handle, const detail::LibraryPathIdentity& origin); + MessageParserLibrary( std::shared_ptr handle, const PJ_message_parser_vtable_t* vtable, std::string path, - const PJ_dialog_vtable_t* static_dialog_vtable = nullptr); + std::string resolved_path, const PJ_dialog_vtable_t* static_dialog_vtable = nullptr); void reset(); @@ -90,6 +117,7 @@ class MessageParserLibrary { const PJ_message_parser_vtable_t* vtable_ = nullptr; const PJ_dialog_vtable_t* static_dialog_vtable_ = nullptr; std::string path_; + std::string resolved_path_; }; } // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index 903e37a3..098a8735 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -16,7 +16,9 @@ #include #include +#include #include +#include #include #include "pj_base/expected.hpp" @@ -81,6 +83,11 @@ struct PluginScanResult { /// Inspect one DSO and return its embedded plugin descriptor. [[nodiscard]] Expected inspectPluginDso(const std::filesystem::path& dso_path); +/// Inspect one DSO through an already-open @p handle originating at @p dso_path. +/// The supplied shared handle remains owned by the caller and is not reopened. +[[nodiscard]] Expected inspectPluginDso( + const std::shared_ptr& handle, const std::filesystem::path& dso_path); + /// Recursively scan a directory for platform plugin DSOs. Invalid candidates are /// reported in diagnostics while discovery continues. [[nodiscard]] Expected scanPluginDsos(const std::filesystem::path& directory); @@ -88,4 +95,13 @@ struct PluginScanResult { /// Human-readable name for a plugin family. [[nodiscard]] std::string_view toString(PluginFamily family) noexcept; +namespace detail { + +/// Return the plugin families whose getter symbols are defined by @p dso_path +/// itself. Missing getters and getters supplied only by dependencies are omitted. +[[nodiscard]] std::vector exportedPluginFamilies( + const std::shared_ptr& handle, const std::filesystem::path& dso_path); + +} // namespace detail + } // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/toolbox_library.hpp b/pj_plugins/include/pj_plugins/host/toolbox_library.hpp index c0e00ea6..a850c19f 100644 --- a/pj_plugins/include/pj_plugins/host/toolbox_library.hpp +++ b/pj_plugins/include/pj_plugins/host/toolbox_library.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -30,6 +31,10 @@ namespace PJ { +namespace detail { +struct LibraryPathIdentity; +} + /** * Loads a Toolbox plugin shared library and provides factory access. * @@ -51,6 +56,25 @@ class ToolboxLibrary { /// Load a plugin from @p path. Returns an error string on failure. [[nodiscard]] static Expected load(std::string_view path); + /// Preserve an unambiguous load call for existing narrow string paths. + [[nodiscard]] static Expected load(const char* path) { + return load(std::string_view(path)); + } + + /// Preserve an unambiguous load call for existing `std::string` paths. + [[nodiscard]] static Expected load(const std::string& path) { + return load(std::string_view(path)); + } + + /// Load a plugin from a filesystem-native @p path. + [[nodiscard]] static Expected load(const std::filesystem::path& path); + + /// Validate and retain an already-open @p handle whose file is @p origin. + /// The library shares the caller-supplied handle ownership and does not open + /// or close a separate native module during validation. + [[nodiscard]] static Expected loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin); + /// Wrap a statically-linked plugin vtable (no dlopen; for WASM/static builds). /// @p vtable must have static storage duration (valid for the program lifetime). [[nodiscard]] static Expected loadStatic( @@ -80,8 +104,11 @@ class ToolboxLibrary { } private: + [[nodiscard]] static Expected loadFromHandleWithIdentity( + std::shared_ptr handle, const detail::LibraryPathIdentity& origin); + ToolboxLibrary( - std::shared_ptr handle, const PJ_toolbox_vtable_t* vtable, std::string path, + std::shared_ptr handle, const PJ_toolbox_vtable_t* vtable, std::string path, std::string resolved_path, const PJ_dialog_vtable_t* static_dialog_vtable = nullptr); void reset(); @@ -90,6 +117,7 @@ class ToolboxLibrary { const PJ_toolbox_vtable_t* vtable_ = nullptr; const PJ_dialog_vtable_t* static_dialog_vtable_ = nullptr; std::string path_; + std::string resolved_path_; }; } // namespace PJ diff --git a/pj_plugins/src/data_source_library.cpp b/pj_plugins/src/data_source_library.cpp index 87ef1908..d643afad 100644 --- a/pj_plugins/src/data_source_library.cpp +++ b/pj_plugins/src/data_source_library.cpp @@ -11,12 +11,13 @@ namespace PJ { DataSourceLibrary::DataSourceLibrary( - std::shared_ptr handle, const PJ_data_source_vtable_t* vtable, std::string path, + std::shared_ptr handle, const PJ_data_source_vtable_t* vtable, std::string path, std::string resolved_path, const PJ_dialog_vtable_t* static_dialog_vtable) : handle_(std::move(handle)), vtable_(vtable), static_dialog_vtable_(static_dialog_vtable), - path_(std::move(path)) {} + path_(std::move(path)), + resolved_path_(std::move(resolved_path)) {} DataSourceLibrary::~DataSourceLibrary() { reset(); @@ -26,7 +27,8 @@ DataSourceLibrary::DataSourceLibrary(DataSourceLibrary&& other) noexcept : handle_(std::move(other.handle_)), vtable_(other.vtable_), static_dialog_vtable_(other.static_dialog_vtable_), - path_(std::move(other.path_)) { + path_(std::move(other.path_)), + resolved_path_(std::move(other.resolved_path_)) { other.vtable_ = nullptr; other.static_dialog_vtable_ = nullptr; } @@ -38,6 +40,7 @@ DataSourceLibrary& DataSourceLibrary::operator=(DataSourceLibrary&& other) noexc vtable_ = other.vtable_; static_dialog_vtable_ = other.static_dialog_vtable_; path_ = std::move(other.path_); + resolved_path_ = std::move(other.resolved_path_); other.vtable_ = nullptr; other.static_dialog_vtable_ = nullptr; } @@ -45,17 +48,37 @@ DataSourceLibrary& DataSourceLibrary::operator=(DataSourceLibrary&& other) noexc } Expected DataSourceLibrary::load(std::string_view path) { - auto raw_handle = detail::loadLibraryHandle(path); + return load(std::filesystem::path(path)); +} + +Expected DataSourceLibrary::load(const std::filesystem::path& path) { + detail::LibraryPathIdentity recorded_path; + auto raw_handle = detail::loadLibraryHandle(path, &recorded_path); if (!raw_handle) { return unexpected(raw_handle.error()); } - auto handle = detail::adoptLibraryHandle(*raw_handle); + return loadFromHandleWithIdentity(detail::adoptLibraryHandle(*raw_handle), recorded_path); +} - if (auto abi = detail::checkPluginAbiVersion(handle.get()); !abi) { +Expected DataSourceLibrary::loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin) { + auto recorded_path = detail::recordLibraryPathIdentity(origin); + if (!recorded_path) { + return unexpected(recorded_path.error()); + } + return loadFromHandleWithIdentity(std::move(handle), *recorded_path); +} + +Expected DataSourceLibrary::loadFromHandleWithIdentity( + std::shared_ptr handle, const detail::LibraryPathIdentity& recorded_path) { + if (handle == nullptr) { + return unexpected("library not loaded"); + } + if (auto abi = detail::checkPluginAbiVersion(handle.get(), recorded_path); !abi) { return unexpected(abi.error()); } - auto sym = detail::resolveSymbol(handle.get(), "PJ_get_data_source_vtable"); + auto sym = detail::resolveSymbol(handle.get(), "PJ_get_data_source_vtable", recorded_path); if (!sym) { return unexpected(sym.error()); } @@ -77,7 +100,9 @@ Expected DataSourceLibrary::load(std::string_view path) { return unexpected(status.error()); } - return DataSourceLibrary(std::move(handle), vtable, std::string(path)); + return DataSourceLibrary( + std::move(handle), vtable, detail::pathForLegacyAccessor(recorded_path.load_path), + detail::pathForLegacyAccessor(recorded_path.resolved_path)); } Expected DataSourceLibrary::loadStatic( @@ -109,7 +134,7 @@ Expected DataSourceLibrary::loadStatic( // non-null owner. Use a sentinel shared_ptr with a no-op deleter. static char anchor = 0; std::shared_ptr handle(&anchor, [](void*) {}); - return DataSourceLibrary(std::move(handle), vtable, "static://", dialog_vtable); + return DataSourceLibrary(std::move(handle), vtable, "static://", "", dialog_vtable); } Expected DataSourceLibrary::resolveDialogVtable() const { @@ -119,7 +144,12 @@ Expected DataSourceLibrary::resolveDialogVtable() con if (path_ == "static://") { return unexpected("static DataSource has no registered dialog vtable"); } - auto sym = detail::resolveSymbol(handle_.get(), "PJ_get_dialog_vtable"); +#if defined(_WIN32) + auto sym = detail::resolveSymbol(handle_.get(), "PJ_get_dialog_vtable", {}); +#else + auto sym = detail::resolveSymbol( + handle_.get(), "PJ_get_dialog_vtable", {std::filesystem::path(path_), std::filesystem::path(resolved_path_)}); +#endif if (!sym) { return unexpected(sym.error()); } @@ -146,6 +176,7 @@ void DataSourceLibrary::reset() { vtable_ = nullptr; static_dialog_vtable_ = nullptr; path_.clear(); + resolved_path_.clear(); } } diff --git a/pj_plugins/src/detail/library_loader.hpp b/pj_plugins/src/detail/library_loader.hpp index 8b2626a4..b458cb4f 100644 --- a/pj_plugins/src/detail/library_loader.hpp +++ b/pj_plugins/src/detail/library_loader.hpp @@ -3,11 +3,19 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include #include -#include +#include +#include #if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif #include #else #include @@ -18,15 +26,68 @@ namespace PJ::detail { -inline Expected loadLibraryHandle(std::string_view path) { +/// Encode a native path for the legacy narrow `Library::path()` accessor. +inline std::string pathForLegacyAccessor(const std::filesystem::path& path) { +#if defined(_WIN32) + const auto utf8 = path.u8string(); + return std::string(utf8.begin(), utf8.end()); +#else + return path.string(); +#endif +} + +/// The two immutable path spellings captured while a candidate still exists. +/// `load_path` is the exact normalized absolute argument passed to the native +/// loader. `resolved_path` is its best-effort weakly-canonical spelling. +struct LibraryPathIdentity { + std::filesystem::path load_path; + std::filesystem::path resolved_path; +}; + +/// Produce the normalized absolute spelling used for the native loader call. +/// This is deliberately lexical: loading does not require the candidate to +/// remain stat-able after the native module handle has been acquired. +inline Expected normalizedAbsoluteLibraryPath(const std::filesystem::path& path) { + std::error_code path_error; + std::filesystem::path absolute_path = std::filesystem::absolute(path, path_error); + if (path_error) { +#if defined(_WIN32) + return unexpected("cannot make library path absolute: " + path_error.message()); +#else + return unexpected("cannot make library path absolute '" + path.string() + "': " + path_error.message()); +#endif + } + return absolute_path.lexically_normal(); +} + +inline Expected recordLibraryPathIdentity(const std::filesystem::path& path) { + auto load_path = normalizedAbsoluteLibraryPath(path); + if (!load_path) { + return unexpected(load_path.error()); + } + + std::error_code canonical_error; + std::filesystem::path resolved_path = std::filesystem::weakly_canonical(*load_path, canonical_error); + if (canonical_error) { + resolved_path.clear(); + } + return LibraryPathIdentity{std::move(*load_path), std::move(resolved_path)}; +} + +inline Expected loadLibraryHandle( + const std::filesystem::path& path, LibraryPathIdentity* recorded_path = nullptr) { + auto identity = recordLibraryPathIdentity(path); + if (!identity) { + return unexpected(identity.error()); + } #if defined(_WIN32) - // LOAD_WITH_ALTERED_SEARCH_PATH adds the directory of the loaded DLL to the - // search path for resolving its dependencies — matches dlopen's default on - // Linux. Without it, deps are only searched in the .exe directory, System32 - // and PATH, so plugins cannot ship their own sibling DLLs - HMODULE module = LoadLibraryExA(std::string(path).c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); + HMODULE module = LoadLibraryExW( + identity->load_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); if (module == nullptr) { - return unexpected("LoadLibraryExA failed (error " + std::to_string(GetLastError()) + ")"); + return unexpected("LoadLibraryExW failed (error " + std::to_string(GetLastError()) + ")"); + } + if (recorded_path != nullptr) { + *recorded_path = *identity; } return reinterpret_cast(module); #else @@ -51,17 +112,102 @@ inline Expected loadLibraryHandle(std::string_view path) { // malloc/pthread/system calls are NOT defined in the plugin so they still // reach the host — ASAN malloc interposition works correctly. int flags = RTLD_NOW | RTLD_LOCAL; - void* handle = dlopen(std::string(path).c_str(), flags); +#if defined(__APPLE__) + // Restrict handle-scoped lookups to the candidate image. Dylibs that rely on + // -reexport_library no longer resolve through this handle; that stricter + // admission behavior is intentional and provenance diagnostics stay explicit. + flags |= RTLD_FIRST; +#endif + void* handle = dlopen(identity->load_path.c_str(), flags); if (handle == nullptr) { const char* error = dlerror(); return unexpected(error == nullptr ? "" : error); } + if (recorded_path != nullptr) { + *recorded_path = *identity; + } return handle; #endif } -/// Resolve a named symbol from a loaded library handle. -inline Expected resolveSymbol(void* handle, const char* symbol_name) { +/// Return the filesystem object that defines @p symbol on POSIX platforms. +inline Expected symbolOwner(void* symbol) { +#if defined(_WIN32) + (void)symbol; + return std::filesystem::path{}; +#else + Dl_info info{}; + if (symbol == nullptr || dladdr(symbol, &info) == 0 || info.dli_fname == nullptr || info.dli_fname[0] == '\0') { + return unexpected("dladdr failed to identify the defining object"); + } + return std::filesystem::path(info.dli_fname); +#endif +} + +/// Verify that @p symbol is defined by @p candidate_handle/path, not a +/// dependency. POSIX accepts an exact recorded loader-path match without any +/// filesystem access, then uses equivalent() only for different spellings. +inline Expected verifySymbolProvenance( + void* candidate_handle, void* symbol, const char* symbol_name, const LibraryPathIdentity& candidate_path) { +#if defined(_WIN32) + HMODULE owner = nullptr; + if (symbol == nullptr || GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(symbol), &owner) == 0) { + return unexpected( + "cannot prove provenance for symbol '" + std::string(symbol_name) + "': GetModuleHandleExW failed (error " + + std::to_string(GetLastError()) + ")"); + } + const auto candidate = reinterpret_cast(candidate_handle); + if (owner != candidate) { + auto module_path = [](HMODULE module) { + std::wstring buffer(32768, L'\0'); + const DWORD length = GetModuleFileNameW(module, buffer.data(), static_cast(buffer.size())); + if (length == 0 || length >= static_cast(buffer.size())) { + return std::string(""); + } + buffer.resize(length); + return pathForLegacyAccessor(std::filesystem::path(buffer)); + }; + const std::string candidate_name = + candidate_path.load_path.empty() ? module_path(candidate) : pathForLegacyAccessor(candidate_path.load_path); + return unexpected( + "symbol '" + std::string(symbol_name) + "' resolved from dependency '" + module_path(owner) + + "', not candidate '" + candidate_name + "'"); + } + return {}; +#else + (void)candidate_handle; + auto owner = symbolOwner(symbol); + if (!owner) { + return unexpected( + "cannot prove provenance for symbol '" + std::string(symbol_name) + "' in candidate '" + + candidate_path.load_path.string() + "': " + owner.error()); + } + + if (owner->native() == candidate_path.load_path.native() || + (!candidate_path.resolved_path.empty() && owner->native() == candidate_path.resolved_path.native())) { + return {}; + } + + std::error_code equivalent_error; + const bool equivalent = std::filesystem::equivalent(*owner, candidate_path.load_path, equivalent_error); + if (equivalent_error) { + return unexpected( + "cannot prove provenance for symbol '" + std::string(symbol_name) + "': defining object '" + owner->string() + + "', candidate '" + candidate_path.load_path.string() + "': " + equivalent_error.message()); + } + if (!equivalent) { + return unexpected( + "symbol '" + std::string(symbol_name) + "' resolved from dependency '" + owner->string() + + "', not candidate '" + candidate_path.load_path.string() + "'"); + } + return {}; +#endif +} + +/// Resolve a named symbol and prove that it is defined by @p candidate_path. +inline Expected resolveSymbol(void* handle, const char* symbol_name, const LibraryPathIdentity& candidate_path) { if (handle == nullptr) { return unexpected("library not loaded"); } @@ -71,23 +217,33 @@ inline Expected resolveSymbol(void* handle, const char* symbol_name) { std::string name(symbol_name); return unexpected(name + " not found"); } - return reinterpret_cast(symbol); + void* resolved = reinterpret_cast(symbol); #else dlerror(); void* symbol = dlsym(handle, symbol_name); const char* err = dlerror(); if (err != nullptr) { +#if defined(__APPLE__) + return unexpected( + "cannot prove provenance for symbol '" + std::string(symbol_name) + "' in candidate '" + + candidate_path.load_path.string() + "': RTLD_FIRST lookup failed: " + err); +#else return unexpected(err); +#endif } - return symbol; + void* resolved = symbol; #endif + if (auto provenance = verifySymbolProvenance(handle, resolved, symbol_name, candidate_path); !provenance) { + return unexpected(provenance.error()); + } + return resolved; } /// Verify the plugin exports `pj_plugin_abi_version` and its value equals /// PJ_ABI_VERSION. Must be called BEFORE the family vtable is fetched — the /// vtable layout is only meaningful once the boot-level ABI matches. -inline Expected checkPluginAbiVersion(void* handle) { - auto sym = resolveSymbol(handle, "pj_plugin_abi_version"); +inline Expected checkPluginAbiVersion(void* handle, const LibraryPathIdentity& candidate_path) { + auto sym = resolveSymbol(handle, "pj_plugin_abi_version", candidate_path); if (!sym) { return unexpected("plugin missing pj_plugin_abi_version symbol: " + sym.error()); } @@ -115,4 +271,10 @@ inline std::shared_ptr adoptLibraryHandle(void* handle) { return std::shared_ptr(handle, [](void* loaded_handle) { closeLibraryHandle(loaded_handle); }); } +/// Wrap an already-open library handle with a no-op deleter so process exit can +/// reclaim it after all SDK admission passes share the same native open. +inline std::shared_ptr adoptLibraryHandleNonOwning(void* handle) { + return std::shared_ptr(handle, [](void*) {}); +} + } // namespace PJ::detail diff --git a/pj_plugins/src/detail/native_parser_module_loader.hpp b/pj_plugins/src/detail/native_parser_module_loader.hpp index 8dd4254e..e941b125 100644 --- a/pj_plugins/src/detail/native_parser_module_loader.hpp +++ b/pj_plugins/src/detail/native_parser_module_loader.hpp @@ -4,29 +4,22 @@ #include #include -#include +#include #include #include -#if defined(_WIN32) -#ifndef NOMINMAX -#define NOMINMAX -#endif -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#else -#include -#endif - +#include "detail/library_loader.hpp" #include "pj_base/expected.hpp" namespace PJ::detail { using NativeModuleHandle = void*; -inline Expected openNativeParserModule(std::string_view path) { +/// Open a narrow native parser-module path. Narrow parser-module paths are +/// UTF-8 by contract, including on Windows where filesystem::path(char*) would +/// otherwise interpret them using the active ANSI code page. +inline Expected openNativeParserModule( + std::string_view path, LibraryPathIdentity* recorded_path = nullptr) { #if defined(_WIN32) if (path.size() > static_cast(INT_MAX)) { return unexpected("native parser-module path is too long"); @@ -41,41 +34,20 @@ inline Expected openNativeParserModule(std::string_view path CP_UTF8, MB_ERR_INVALID_CHARS, path.data(), static_cast(path.size()), wide_path.data(), required) == 0) { return unexpected("native parser-module path conversion failed"); } - HMODULE module = - LoadLibraryExW(wide_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); - if (module == nullptr) { - return unexpected("LoadLibraryExW failed (error " + std::to_string(GetLastError()) + ")"); - } - return reinterpret_cast(module); + return loadLibraryHandle(std::filesystem::path(wide_path), recorded_path); #else - void* handle = dlopen(std::string(path).c_str(), RTLD_LOCAL | RTLD_NOW); - if (handle == nullptr) { - const char* error = dlerror(); - return unexpected(error == nullptr ? "dlopen failed" : error); - } - return handle; + return loadLibraryHandle(std::filesystem::path(std::string(path)), recorded_path); #endif } -inline Expected resolveNativeParserModuleSymbol(NativeModuleHandle handle, const char* name) { +/// Resolve a required parser-module export and apply the same defining-module +/// provenance policy as the family plugin loaders. +inline Expected resolveNativeParserModuleSymbol( + NativeModuleHandle handle, const char* name, const LibraryPathIdentity& candidate_path) { if (handle == nullptr) { return unexpected("native parser module is not loaded"); } -#if defined(_WIN32) - FARPROC symbol = GetProcAddress(reinterpret_cast(handle), name); - if (symbol == nullptr) { - return unexpected(std::string(name) + " not found"); - } - return reinterpret_cast(symbol); -#else - dlerror(); - void* symbol = dlsym(handle, name); - const char* error = dlerror(); - if (error != nullptr) { - return unexpected(std::string(name) + " not found: " + error); - } - return symbol; -#endif + return resolveSymbol(handle, name, candidate_path); } } // namespace PJ::detail diff --git a/pj_plugins/src/message_parser_library.cpp b/pj_plugins/src/message_parser_library.cpp index 5e2b58f5..b76d48e9 100644 --- a/pj_plugins/src/message_parser_library.cpp +++ b/pj_plugins/src/message_parser_library.cpp @@ -11,12 +11,13 @@ namespace PJ { MessageParserLibrary::MessageParserLibrary( - std::shared_ptr handle, const PJ_message_parser_vtable_t* vtable, std::string path, + std::shared_ptr handle, const PJ_message_parser_vtable_t* vtable, std::string path, std::string resolved_path, const PJ_dialog_vtable_t* static_dialog_vtable) : handle_(std::move(handle)), vtable_(vtable), static_dialog_vtable_(static_dialog_vtable), - path_(std::move(path)) {} + path_(std::move(path)), + resolved_path_(std::move(resolved_path)) {} MessageParserLibrary::~MessageParserLibrary() { reset(); @@ -26,7 +27,8 @@ MessageParserLibrary::MessageParserLibrary(MessageParserLibrary&& other) noexcep : handle_(std::move(other.handle_)), vtable_(other.vtable_), static_dialog_vtable_(other.static_dialog_vtable_), - path_(std::move(other.path_)) { + path_(std::move(other.path_)), + resolved_path_(std::move(other.resolved_path_)) { other.vtable_ = nullptr; other.static_dialog_vtable_ = nullptr; } @@ -38,6 +40,7 @@ MessageParserLibrary& MessageParserLibrary::operator=(MessageParserLibrary&& oth vtable_ = other.vtable_; static_dialog_vtable_ = other.static_dialog_vtable_; path_ = std::move(other.path_); + resolved_path_ = std::move(other.resolved_path_); other.vtable_ = nullptr; other.static_dialog_vtable_ = nullptr; } @@ -45,17 +48,37 @@ MessageParserLibrary& MessageParserLibrary::operator=(MessageParserLibrary&& oth } Expected MessageParserLibrary::load(std::string_view path) { - auto raw_handle = detail::loadLibraryHandle(path); + return load(std::filesystem::path(path)); +} + +Expected MessageParserLibrary::load(const std::filesystem::path& path) { + detail::LibraryPathIdentity recorded_path; + auto raw_handle = detail::loadLibraryHandle(path, &recorded_path); if (!raw_handle) { return unexpected(raw_handle.error()); } - auto handle = detail::adoptLibraryHandle(*raw_handle); + return loadFromHandleWithIdentity(detail::adoptLibraryHandle(*raw_handle), recorded_path); +} - if (auto abi = detail::checkPluginAbiVersion(handle.get()); !abi) { +Expected MessageParserLibrary::loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin) { + auto recorded_path = detail::recordLibraryPathIdentity(origin); + if (!recorded_path) { + return unexpected(recorded_path.error()); + } + return loadFromHandleWithIdentity(std::move(handle), *recorded_path); +} + +Expected MessageParserLibrary::loadFromHandleWithIdentity( + std::shared_ptr handle, const detail::LibraryPathIdentity& recorded_path) { + if (handle == nullptr) { + return unexpected("library not loaded"); + } + if (auto abi = detail::checkPluginAbiVersion(handle.get(), recorded_path); !abi) { return unexpected(abi.error()); } - auto sym = detail::resolveSymbol(handle.get(), "PJ_get_message_parser_vtable"); + auto sym = detail::resolveSymbol(handle.get(), "PJ_get_message_parser_vtable", recorded_path); if (!sym) { return unexpected(sym.error()); } @@ -75,7 +98,9 @@ Expected MessageParserLibrary::load(std::string_view path) return unexpected(status.error()); } - return MessageParserLibrary(std::move(handle), vtable, std::string(path)); + return MessageParserLibrary( + std::move(handle), vtable, detail::pathForLegacyAccessor(recorded_path.load_path), + detail::pathForLegacyAccessor(recorded_path.resolved_path)); } Expected MessageParserLibrary::loadStatic( @@ -105,7 +130,7 @@ Expected MessageParserLibrary::loadStatic( } static char anchor = 0; std::shared_ptr handle(&anchor, [](void*) {}); - return MessageParserLibrary(std::move(handle), vtable, "static://", dialog_vtable); + return MessageParserLibrary(std::move(handle), vtable, "static://", "", dialog_vtable); } Expected MessageParserLibrary::resolveDialogVtable() const { @@ -115,7 +140,12 @@ Expected MessageParserLibrary::resolveDialogVtable() if (path_ == "static://") { return unexpected("static MessageParser has no registered dialog vtable"); } - auto sym = detail::resolveSymbol(handle_.get(), "PJ_get_dialog_vtable"); +#if defined(_WIN32) + auto sym = detail::resolveSymbol(handle_.get(), "PJ_get_dialog_vtable", {}); +#else + auto sym = detail::resolveSymbol( + handle_.get(), "PJ_get_dialog_vtable", {std::filesystem::path(path_), std::filesystem::path(resolved_path_)}); +#endif if (!sym) { return unexpected(sym.error()); } @@ -142,6 +172,7 @@ void MessageParserLibrary::reset() { vtable_ = nullptr; static_dialog_vtable_ = nullptr; path_.clear(); + resolved_path_.clear(); } } diff --git a/pj_plugins/src/native_parser_module.cpp b/pj_plugins/src/native_parser_module.cpp index d1830562..0a9a181f 100644 --- a/pj_plugins/src/native_parser_module.cpp +++ b/pj_plugins/src/native_parser_module.cpp @@ -3,6 +3,7 @@ #include "pj_plugins/host/native_parser_module.hpp" +#include #include #include #include @@ -46,8 +47,9 @@ Expected rejectLoad( } template -Expected resolve(detail::NativeModuleHandle handle, const char* name) { - auto symbol = detail::resolveNativeParserModuleSymbol(handle, name); +Expected resolve( + detail::NativeModuleHandle handle, const char* name, const detail::LibraryPathIdentity& candidate_path) { + auto symbol = detail::resolveNativeParserModuleSymbol(handle, name, candidate_path); if (!symbol) { return unexpected(symbol.error()); } @@ -61,7 +63,8 @@ NativeParserModule::NativeParserModule(std::shared_ptr NativeParserModule::load( std::string_view path, DiagnosticSink sink, std::string diagnostic_source) { - auto handle_result = detail::openNativeParserModule(path); + detail::LibraryPathIdentity recorded_path; + auto handle_result = detail::openNativeParserModule(path, &recorded_path); if (!handle_result) { return rejectLoad(path, sink, diagnostic_source, "failed to open native parser module: " + handle_result.error()); } @@ -74,7 +77,7 @@ Expected NativeParserModule::load( #define PJ_RESOLVE_MODULE_EXPORT(member, type, name) \ do { \ - auto resolved = resolve(handle, name); \ + auto resolved = resolve(handle, name, recorded_path); \ if (!resolved) { \ return rejectLoad(path, sink, diagnostic_source, resolved.error()); \ } \ diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 1f06ca41..6beb978a 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -39,12 +39,6 @@ struct ManifestCandidate { std::string manifest_json; }; -struct LibraryHandleCloser { - void operator()(void* handle) const { - detail::closeLibraryHandle(handle); - } -}; - bool hasDsoSuffix(const std::filesystem::path& path) { return path.extension().string() == kDsoSuffix; } @@ -54,9 +48,9 @@ bool hasDsoSuffix(const std::filesystem::path& path) { // Only the family-specific types and constants vary. template Expected probeDirectVtable( - void* handle, const char* symbol, const char* family_name, uint32_t expected_protocol, size_t min_vtable_size, - PluginFamily family) { - auto sym = detail::resolveSymbol(handle, symbol); + void* handle, const detail::LibraryPathIdentity& origin, const char* symbol, const char* family_name, + uint32_t expected_protocol, size_t min_vtable_size, PluginFamily family) { + auto sym = detail::resolveSymbol(handle, symbol, origin); if (!sym) { return unexpected(sym.error()); } @@ -76,26 +70,26 @@ Expected probeDirectVtable( return ManifestCandidate{family, vt->manifest_json == nullptr ? "" : vt->manifest_json}; } -Expected tryDataSource(void* handle) { +Expected tryDataSource(void* handle, const detail::LibraryPathIdentity& origin) { return probeDirectVtable( - handle, "PJ_get_data_source_vtable", "DataSource", PJ_DATA_SOURCE_PROTOCOL_VERSION, + handle, origin, "PJ_get_data_source_vtable", "DataSource", PJ_DATA_SOURCE_PROTOCOL_VERSION, PJ_DATA_SOURCE_MIN_VTABLE_SIZE, PluginFamily::kDataSource); } -Expected tryMessageParser(void* handle) { +Expected tryMessageParser(void* handle, const detail::LibraryPathIdentity& origin) { return probeDirectVtable( - handle, "PJ_get_message_parser_vtable", "MessageParser", PJ_MESSAGE_PARSER_PROTOCOL_VERSION, + handle, origin, "PJ_get_message_parser_vtable", "MessageParser", PJ_MESSAGE_PARSER_PROTOCOL_VERSION, PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE, PluginFamily::kMessageParser); } -Expected tryToolbox(void* handle) { +Expected tryToolbox(void* handle, const detail::LibraryPathIdentity& origin) { return probeDirectVtable( - handle, "PJ_get_toolbox_vtable", "Toolbox", PJ_TOOLBOX_PLUGIN_PROTOCOL_VERSION, PJ_TOOLBOX_MIN_VTABLE_SIZE, - PluginFamily::kToolbox); + handle, origin, "PJ_get_toolbox_vtable", "Toolbox", PJ_TOOLBOX_PLUGIN_PROTOCOL_VERSION, + PJ_TOOLBOX_MIN_VTABLE_SIZE, PluginFamily::kToolbox); } -Expected tryDialog(void* handle) { - auto sym = detail::resolveSymbol(handle, "PJ_get_dialog_vtable"); +Expected tryDialog(void* handle, const detail::LibraryPathIdentity& origin) { + auto sym = detail::resolveSymbol(handle, "PJ_get_dialog_vtable", origin); if (!sym) { return unexpected(sym.error()); } @@ -127,28 +121,28 @@ Expected tryDialog(void* handle) { return ManifestCandidate{PluginFamily::kDialog, std::move(manifest_json)}; } -Expected findEmbeddedManifest(void* handle) { +Expected findEmbeddedManifest(void* handle, const detail::LibraryPathIdentity& origin) { std::vector errors; - if (auto candidate = tryDataSource(handle)) { + if (auto candidate = tryDataSource(handle, origin)) { return *candidate; } else { errors.push_back(fmt::format("data_source: {}", candidate.error())); } - if (auto candidate = tryMessageParser(handle)) { + if (auto candidate = tryMessageParser(handle, origin)) { return *candidate; } else { errors.push_back(fmt::format("message_parser: {}", candidate.error())); } - if (auto candidate = tryToolbox(handle)) { + if (auto candidate = tryToolbox(handle, origin)) { return *candidate; } else { errors.push_back(fmt::format("toolbox: {}", candidate.error())); } - if (auto candidate = tryDialog(handle)) { + if (auto candidate = tryDialog(handle, origin)) { return *candidate; } else { errors.push_back(fmt::format("dialog: {}", candidate.error())); @@ -312,19 +306,35 @@ Expected inspectPluginDso(const std::filesystem::path& dso_pat if (!hasDsoSuffix(dso_path)) { return unexpected(fmt::format("not a platform plugin DSO: {}", dso_path.string())); } + + detail::LibraryPathIdentity recorded_path; + auto raw_handle = detail::loadLibraryHandle(dso_path, &recorded_path); + if (!raw_handle) { + return unexpected(fmt::format("{}: {}", dso_path.string(), raw_handle.error())); + } + return inspectPluginDso(detail::adoptLibraryHandle(*raw_handle), recorded_path.load_path); +} + +Expected inspectPluginDso( + const std::shared_ptr& handle, const std::filesystem::path& dso_path) { + if (!hasDsoSuffix(dso_path)) { + return unexpected(fmt::format("not a platform plugin DSO: {}", dso_path.string())); + } auto with_path = [&](const std::string& error) { return fmt::format("{}: {}", dso_path.string(), error); }; + if (handle == nullptr) { + return unexpected(with_path("library not loaded")); + } - auto handle = detail::loadLibraryHandle(dso_path.string()); - if (!handle) { - return unexpected(with_path(handle.error())); + auto recorded_path = detail::recordLibraryPathIdentity(dso_path); + if (!recorded_path) { + return unexpected(with_path(recorded_path.error())); } - std::unique_ptr library(*handle); - if (auto abi = detail::checkPluginAbiVersion(library.get()); !abi) { + if (auto abi = detail::checkPluginAbiVersion(handle.get(), *recorded_path); !abi) { return unexpected(with_path(abi.error())); } - auto candidate = findEmbeddedManifest(library.get()); + auto candidate = findEmbeddedManifest(handle.get(), *recorded_path); if (!candidate) { return unexpected(with_path(candidate.error())); } @@ -336,6 +346,33 @@ Expected inspectPluginDso(const std::filesystem::path& dso_pat return *descriptor; } +namespace detail { + +std::vector exportedPluginFamilies( + const std::shared_ptr& handle, const std::filesystem::path& dso_path) { + std::vector families; + if (handle == nullptr) { + return families; + } + auto recorded_path = recordLibraryPathIdentity(dso_path); + if (!recorded_path) { + return families; + } + + auto append_if_owned = [&](const char* symbol, PluginFamily family) { + if (resolveSymbol(handle.get(), symbol, *recorded_path)) { + families.push_back(family); + } + }; + append_if_owned("PJ_get_data_source_vtable", PluginFamily::kDataSource); + append_if_owned("PJ_get_message_parser_vtable", PluginFamily::kMessageParser); + append_if_owned("PJ_get_toolbox_vtable", PluginFamily::kToolbox); + append_if_owned("PJ_get_dialog_vtable", PluginFamily::kDialog); + return families; +} + +} // namespace detail + Expected scanPluginDsos(const std::filesystem::path& directory) { std::error_code ec; if (!std::filesystem::exists(directory, ec)) { diff --git a/pj_plugins/src/toolbox_library.cpp b/pj_plugins/src/toolbox_library.cpp index 9986a1da..9842ec5a 100644 --- a/pj_plugins/src/toolbox_library.cpp +++ b/pj_plugins/src/toolbox_library.cpp @@ -11,12 +11,13 @@ namespace PJ { ToolboxLibrary::ToolboxLibrary( - std::shared_ptr handle, const PJ_toolbox_vtable_t* vtable, std::string path, + std::shared_ptr handle, const PJ_toolbox_vtable_t* vtable, std::string path, std::string resolved_path, const PJ_dialog_vtable_t* static_dialog_vtable) : handle_(std::move(handle)), vtable_(vtable), static_dialog_vtable_(static_dialog_vtable), - path_(std::move(path)) {} + path_(std::move(path)), + resolved_path_(std::move(resolved_path)) {} ToolboxLibrary::~ToolboxLibrary() { reset(); @@ -26,7 +27,8 @@ ToolboxLibrary::ToolboxLibrary(ToolboxLibrary&& other) noexcept : handle_(std::move(other.handle_)), vtable_(other.vtable_), static_dialog_vtable_(other.static_dialog_vtable_), - path_(std::move(other.path_)) { + path_(std::move(other.path_)), + resolved_path_(std::move(other.resolved_path_)) { other.vtable_ = nullptr; other.static_dialog_vtable_ = nullptr; } @@ -38,6 +40,7 @@ ToolboxLibrary& ToolboxLibrary::operator=(ToolboxLibrary&& other) noexcept { vtable_ = other.vtable_; static_dialog_vtable_ = other.static_dialog_vtable_; path_ = std::move(other.path_); + resolved_path_ = std::move(other.resolved_path_); other.vtable_ = nullptr; other.static_dialog_vtable_ = nullptr; } @@ -45,17 +48,37 @@ ToolboxLibrary& ToolboxLibrary::operator=(ToolboxLibrary&& other) noexcept { } Expected ToolboxLibrary::load(std::string_view path) { - auto raw_handle = detail::loadLibraryHandle(path); + return load(std::filesystem::path(path)); +} + +Expected ToolboxLibrary::load(const std::filesystem::path& path) { + detail::LibraryPathIdentity recorded_path; + auto raw_handle = detail::loadLibraryHandle(path, &recorded_path); if (!raw_handle) { return unexpected(raw_handle.error()); } - auto handle = detail::adoptLibraryHandle(*raw_handle); + return loadFromHandleWithIdentity(detail::adoptLibraryHandle(*raw_handle), recorded_path); +} - if (auto abi = detail::checkPluginAbiVersion(handle.get()); !abi) { +Expected ToolboxLibrary::loadFromHandle( + std::shared_ptr handle, const std::filesystem::path& origin) { + auto recorded_path = detail::recordLibraryPathIdentity(origin); + if (!recorded_path) { + return unexpected(recorded_path.error()); + } + return loadFromHandleWithIdentity(std::move(handle), *recorded_path); +} + +Expected ToolboxLibrary::loadFromHandleWithIdentity( + std::shared_ptr handle, const detail::LibraryPathIdentity& recorded_path) { + if (handle == nullptr) { + return unexpected("library not loaded"); + } + if (auto abi = detail::checkPluginAbiVersion(handle.get(), recorded_path); !abi) { return unexpected(abi.error()); } - auto sym = detail::resolveSymbol(handle.get(), "PJ_get_toolbox_vtable"); + auto sym = detail::resolveSymbol(handle.get(), "PJ_get_toolbox_vtable", recorded_path); if (!sym) { return unexpected(sym.error()); } @@ -75,7 +98,9 @@ Expected ToolboxLibrary::load(std::string_view path) { return unexpected(status.error()); } - return ToolboxLibrary(std::move(handle), vtable, std::string(path)); + return ToolboxLibrary( + std::move(handle), vtable, detail::pathForLegacyAccessor(recorded_path.load_path), + detail::pathForLegacyAccessor(recorded_path.resolved_path)); } Expected ToolboxLibrary::loadStatic( @@ -105,7 +130,7 @@ Expected ToolboxLibrary::loadStatic( } static char anchor = 0; std::shared_ptr handle(&anchor, [](void*) {}); - return ToolboxLibrary(std::move(handle), vtable, "static://", dialog_vtable); + return ToolboxLibrary(std::move(handle), vtable, "static://", "", dialog_vtable); } Expected ToolboxLibrary::resolveDialogVtable() const { @@ -115,7 +140,12 @@ Expected ToolboxLibrary::resolveDialogVtable() const if (path_ == "static://") { return unexpected("static Toolbox has no registered dialog vtable"); } - auto sym = detail::resolveSymbol(handle_.get(), "PJ_get_dialog_vtable"); +#if defined(_WIN32) + auto sym = detail::resolveSymbol(handle_.get(), "PJ_get_dialog_vtable", {}); +#else + auto sym = detail::resolveSymbol( + handle_.get(), "PJ_get_dialog_vtable", {std::filesystem::path(path_), std::filesystem::path(resolved_path_)}); +#endif if (!sym) { return unexpected(sym.error()); } @@ -142,6 +172,7 @@ void ToolboxLibrary::reset() { vtable_ = nullptr; static_dialog_vtable_ = nullptr; path_.clear(); + resolved_path_.clear(); } } diff --git a/pj_plugins/tests/dependency_search_candidate.cpp b/pj_plugins/tests/dependency_search_candidate.cpp new file mode 100644 index 00000000..7ed09eb7 --- /dev/null +++ b/pj_plugins/tests/dependency_search_candidate.cpp @@ -0,0 +1,85 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/data_source_protocol.h" +#include "pj_base/plugin_abi_export.hpp" + +#if defined(_WIN32) +#define PJ_FIXTURE_IMPORT __declspec(dllimport) +#else +#define PJ_FIXTURE_IMPORT +#endif + +extern "C" PJ_FIXTURE_IMPORT const char* pj_dependency_search_manifest() noexcept; + +namespace { + +void* create() noexcept { + return reinterpret_cast(0x1); +} + +void destroy(void*) noexcept {} + +uint64_t capabilities(void*) noexcept { + return 0; +} + +bool ok(void*, PJ_service_registry_t, PJ_error_t*) noexcept { + return true; +} + +bool saveConfig(void*, PJ_string_view_t* out_json, PJ_error_t*) noexcept { + static constexpr char kJson[] = "{}"; + if (out_json != nullptr) { + out_json->data = kJson; + out_json->size = 2; + } + return true; +} + +bool loadConfig(void*, PJ_string_view_t, PJ_error_t*) noexcept { + return true; +} + +bool action(void*, PJ_error_t*) noexcept { + return true; +} + +void stop(void*) noexcept {} + +PJ_data_source_state_t state(void*) noexcept { + return PJ_DATA_SOURCE_STATE_IDLE; +} + +PJ_borrowed_dialog_t dialog(void*) noexcept { + return PJ_borrowed_dialog_t{nullptr, nullptr}; +} + +const void* extension(void*, PJ_string_view_t) noexcept { + return nullptr; +} + +} // namespace + +extern "C" PJ_DATA_SOURCE_EXPORT const PJ_data_source_vtable_t* PJ_get_data_source_vtable() noexcept { + static const PJ_data_source_vtable_t vtable = { + .protocol_version = PJ_DATA_SOURCE_PROTOCOL_VERSION, + .struct_size = sizeof(PJ_data_source_vtable_t), + .create = create, + .destroy = destroy, + .manifest_json = pj_dependency_search_manifest(), + .capabilities = capabilities, + .bind = ok, + .save_config = saveConfig, + .load_config = loadConfig, + .start = action, + .stop = stop, + .pause = action, + .resume = action, + .poll = action, + .current_state = state, + .get_dialog = dialog, + .get_plugin_extension = extension, + }; + return &vtable; +} diff --git a/pj_plugins/tests/dependency_search_dependency.cpp b/pj_plugins/tests/dependency_search_dependency.cpp new file mode 100644 index 00000000..79d00e21 --- /dev/null +++ b/pj_plugins/tests/dependency_search_dependency.cpp @@ -0,0 +1,16 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#if defined(_WIN32) +#define PJ_FIXTURE_EXPORT __declspec(dllexport) +#else +#define PJ_FIXTURE_EXPORT __attribute__((visibility("default"))) +#endif + +extern "C" PJ_FIXTURE_EXPORT const char* pj_dependency_search_manifest() noexcept { +#if defined(PJ_DEPENDENCY_SEARCH_DECOY) + return R"({"id":"dependency-search-decoy","name":"Dependency Search Decoy","version":"1.0.0"})"; +#else + return R"({"id":"dependency-search-real","name":"Dependency Search Real","version":"1.0.0"})"; +#endif +} diff --git a/pj_plugins/tests/entry_point_donor.cpp b/pj_plugins/tests/entry_point_donor.cpp new file mode 100644 index 00000000..f8355ff3 --- /dev/null +++ b/pj_plugins/tests/entry_point_donor.cpp @@ -0,0 +1,81 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/data_source_protocol.h" +#include "pj_base/plugin_abi_export.hpp" + +extern "C" PJ_DATA_SOURCE_EXPORT int pj_entry_point_donor_marker() noexcept { + return 1; +} + +namespace { + +void* create() noexcept { + return reinterpret_cast(0x1); +} + +void destroy(void*) noexcept {} + +uint64_t capabilities(void*) noexcept { + return 0; +} + +bool ok(void*, PJ_service_registry_t, PJ_error_t*) noexcept { + return true; +} + +bool saveConfig(void*, PJ_string_view_t* out_json, PJ_error_t*) noexcept { + static constexpr char kJson[] = "{}"; + if (out_json != nullptr) { + out_json->data = kJson; + out_json->size = 2; + } + return true; +} + +bool loadConfig(void*, PJ_string_view_t, PJ_error_t*) noexcept { + return true; +} + +bool action(void*, PJ_error_t*) noexcept { + return true; +} + +void stop(void*) noexcept {} + +PJ_data_source_state_t state(void*) noexcept { + return PJ_DATA_SOURCE_STATE_IDLE; +} + +PJ_borrowed_dialog_t dialog(void*) noexcept { + return PJ_borrowed_dialog_t{nullptr, nullptr}; +} + +const void* extension(void*, PJ_string_view_t) noexcept { + return nullptr; +} + +} // namespace + +extern "C" PJ_DATA_SOURCE_EXPORT const PJ_data_source_vtable_t* PJ_get_data_source_vtable() noexcept { + static const PJ_data_source_vtable_t vtable = { + .protocol_version = PJ_DATA_SOURCE_PROTOCOL_VERSION, + .struct_size = sizeof(PJ_data_source_vtable_t), + .create = create, + .destroy = destroy, + .manifest_json = R"({"id":"entry-point-donor","name":"Entry Point Donor","version":"1.0.0"})", + .capabilities = capabilities, + .bind = ok, + .save_config = saveConfig, + .load_config = loadConfig, + .start = action, + .stop = stop, + .pause = action, + .resume = action, + .poll = action, + .current_state = state, + .get_dialog = dialog, + .get_plugin_extension = extension, + }; + return &vtable; +} diff --git a/pj_plugins/tests/entry_point_forwarder.cpp b/pj_plugins/tests/entry_point_forwarder.cpp new file mode 100644 index 00000000..2a0d8973 --- /dev/null +++ b/pj_plugins/tests/entry_point_forwarder.cpp @@ -0,0 +1,12 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/plugin_abi_export.hpp" + +#if defined(_MSC_VER) +// PE forwarder for the family getter: the export resolves at GetProcAddress +// time from entry_point_donor.dll. The /export directive form is required +// because MSVC LINK parses the equivalent .def EXPORTS entry as an +// internal-name alias and demands a local definition (LNK2001). +#pragma comment(linker, "/export:PJ_get_data_source_vtable=entry_point_donor.PJ_get_data_source_vtable") +#endif diff --git a/pj_plugins/tests/entry_point_via_dependency.cpp b/pj_plugins/tests/entry_point_via_dependency.cpp new file mode 100644 index 00000000..788f9daa --- /dev/null +++ b/pj_plugins/tests/entry_point_via_dependency.cpp @@ -0,0 +1,20 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/plugin_data_api.h" + +#if defined(_WIN32) +#define PJ_FIXTURE_IMPORT __declspec(dllimport) +#else +#define PJ_FIXTURE_IMPORT +#endif + +extern "C" PJ_FIXTURE_IMPORT int pj_entry_point_donor_marker() noexcept; + +namespace { + +// Force the donor to remain in the candidate's dependency closure even when +// the toolchain links shared libraries with --as-needed. +[[maybe_unused]] const int kKeepDonorDependency = pj_entry_point_donor_marker(); + +} // namespace diff --git a/pj_plugins/tests/entry_point_with_own_exports.cpp b/pj_plugins/tests/entry_point_with_own_exports.cpp new file mode 100644 index 00000000..d510a152 --- /dev/null +++ b/pj_plugins/tests/entry_point_with_own_exports.cpp @@ -0,0 +1,87 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/data_source_protocol.h" +#include "pj_base/plugin_abi_export.hpp" + +#if defined(_WIN32) +#define PJ_FIXTURE_IMPORT __declspec(dllimport) +#else +#define PJ_FIXTURE_IMPORT +#endif + +extern "C" PJ_FIXTURE_IMPORT int pj_entry_point_donor_marker() noexcept; + +namespace { + +[[maybe_unused]] const int kKeepDonorDependency = pj_entry_point_donor_marker(); + +void* create() noexcept { + return reinterpret_cast(0x1); +} + +void destroy(void*) noexcept {} + +uint64_t capabilities(void*) noexcept { + return 0; +} + +bool ok(void*, PJ_service_registry_t, PJ_error_t*) noexcept { + return true; +} + +bool saveConfig(void*, PJ_string_view_t* out_json, PJ_error_t*) noexcept { + static constexpr char kJson[] = "{}"; + if (out_json != nullptr) { + out_json->data = kJson; + out_json->size = 2; + } + return true; +} + +bool loadConfig(void*, PJ_string_view_t, PJ_error_t*) noexcept { + return true; +} + +bool action(void*, PJ_error_t*) noexcept { + return true; +} + +void stop(void*) noexcept {} + +PJ_data_source_state_t state(void*) noexcept { + return PJ_DATA_SOURCE_STATE_IDLE; +} + +PJ_borrowed_dialog_t dialog(void*) noexcept { + return PJ_borrowed_dialog_t{nullptr, nullptr}; +} + +const void* extension(void*, PJ_string_view_t) noexcept { + return nullptr; +} + +} // namespace + +extern "C" PJ_DATA_SOURCE_EXPORT const PJ_data_source_vtable_t* PJ_get_data_source_vtable() noexcept { + static const PJ_data_source_vtable_t vtable = { + .protocol_version = PJ_DATA_SOURCE_PROTOCOL_VERSION, + .struct_size = sizeof(PJ_data_source_vtable_t), + .create = create, + .destroy = destroy, + .manifest_json = R"({"id":"entry-point-candidate","name":"Entry Point Candidate","version":"1.0.0"})", + .capabilities = capabilities, + .bind = ok, + .save_config = saveConfig, + .load_config = loadConfig, + .start = action, + .stop = stop, + .pause = action, + .resume = action, + .poll = action, + .current_state = state, + .get_dialog = dialog, + .get_plugin_extension = extension, + }; + return &vtable; +} diff --git a/pj_plugins/tests/native_parser_module_test.cpp b/pj_plugins/tests/native_parser_module_test.cpp index ad8bdb92..8604f8be 100644 --- a/pj_plugins/tests/native_parser_module_test.cpp +++ b/pj_plugins/tests/native_parser_module_test.cpp @@ -5,6 +5,9 @@ #include +#include +#include +#include #include #include @@ -15,6 +18,42 @@ namespace PJ { namespace { +class NativeModuleTemporaryDirectory { + public: + NativeModuleTemporaryDirectory() + : path_( + std::filesystem::temp_directory_path() / + ("pj_native_module_" + + std::to_string(static_cast(std::chrono::steady_clock::now().time_since_epoch().count())))) { + std::filesystem::create_directories(path_); + } + + ~NativeModuleTemporaryDirectory() { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + + const std::filesystem::path& path() const noexcept { + return path_; + } + + private: + std::filesystem::path path_; +}; + +class NativeModuleCurrentPathGuard { + public: + NativeModuleCurrentPathGuard() : original_(std::filesystem::current_path()) {} + + ~NativeModuleCurrentPathGuard() { + std::error_code error; + std::filesystem::current_path(original_, error); + } + + private: + std::filesystem::path original_; +}; + TEST(NativeParserModule, LoadsCompleteAbiAndCopiesManifestForCatalogAdmission) { std::vector diagnostics; auto module = NativeParserModule::load( @@ -34,6 +73,31 @@ TEST(NativeParserModule, LoadsCompleteAbiAndCopiesManifestForCatalogAdmission) { EXPECT_EQ(catalog.claims().size(), pj_fixture::kClaimCount); } +TEST(NativeParserModule, NativeParserNarrowPathIsUtf8) { + NativeModuleTemporaryDirectory temporary; + const std::filesystem::path unicode_directory = temporary.path() / std::filesystem::path(u8"módulo-解析"); + std::filesystem::create_directories(unicode_directory); + const std::filesystem::path module_path = + unicode_directory / std::filesystem::path(PJ_NATIVE_MODULE_FIXTURE_PATH).filename(); + std::filesystem::copy_file(PJ_NATIVE_MODULE_FIXTURE_PATH, module_path); + + NativeModuleCurrentPathGuard current_path; + std::filesystem::current_path(temporary.path()); + const auto utf8_path = module_path.lexically_relative(temporary.path()).u8string(); + const std::string narrow_path(utf8_path.begin(), utf8_path.end()); + auto module = NativeParserModule::load(narrow_path); + ASSERT_TRUE(module.has_value()) << module.error(); + EXPECT_EQ(module->path(), narrow_path); + EXPECT_NE(module->manifestJson().find("org.plotjuggler.test.native-module"), std::string_view::npos); + +#if defined(_WIN32) + const std::string invalid_utf8 = "invalid-\xff.dll"; + auto invalid = NativeParserModule::load(invalid_utf8); + ASSERT_FALSE(invalid.has_value()); + EXPECT_NE(invalid.error().find("valid UTF-8"), std::string::npos) << invalid.error(); +#endif +} + TEST(NativeParserModule, RejectsEachLoaderFailureWithOneDiagnostic) { for (const std::string path : { PJ_NATIVE_MODULE_MISSING_EXPORT_PATH, diff --git a/pj_plugins/tests/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index 0d78a1d9..a9e80918 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -9,8 +9,23 @@ #include #include #include +#include #include #include +#include + +#include "detail/library_loader.hpp" +#include "pj_plugins/host/data_source_library.hpp" + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif namespace PJ { namespace { @@ -120,6 +135,122 @@ TEST_F(PluginCatalogTest, InspectRequiredPrefixOnlyDialogDsoDoesNotReadStaticMan EXPECT_EQ(descriptor->family, PluginFamily::kDialog); } +TEST_F(PluginCatalogTest, EntryPointSymbolResolvedFromDependencyIsRejected) { +#if !defined(_WIN32) + auto is_provenance_error = [](const std::string& error) { + return error.find("resolved from dependency") != std::string::npos || + error.find("cannot prove provenance") != std::string::npos; + }; + auto dependency_library = DataSourceLibrary::load(PJ_ENTRY_POINT_VIA_DEPENDENCY_PLUGIN_PATH); + ASSERT_FALSE(dependency_library.has_value()); + EXPECT_TRUE(is_provenance_error(dependency_library.error())) << dependency_library.error(); + + auto dependency_descriptor = inspectPluginDso(PJ_ENTRY_POINT_VIA_DEPENDENCY_PLUGIN_PATH); + ASSERT_FALSE(dependency_descriptor.has_value()); + EXPECT_TRUE(is_provenance_error(dependency_descriptor.error())) << dependency_descriptor.error(); +#endif + + auto library = DataSourceLibrary::load(PJ_ENTRY_POINT_WITH_OWN_EXPORTS_PLUGIN_PATH); + ASSERT_TRUE(library.has_value()) << library.error(); + + auto descriptor = inspectPluginDso(PJ_ENTRY_POINT_WITH_OWN_EXPORTS_PLUGIN_PATH); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "entry-point-candidate"); +} + +#if defined(_WIN32) +TEST_F(PluginCatalogTest, ForwardedEntryPointIsRejected) { + auto library = DataSourceLibrary::load(PJ_ENTRY_POINT_FORWARDER_PLUGIN_PATH); + ASSERT_FALSE(library.has_value()); + EXPECT_NE(library.error().find("resolved from dependency"), std::string::npos) << library.error(); + + auto descriptor = inspectPluginDso(PJ_ENTRY_POINT_FORWARDER_PLUGIN_PATH); + ASSERT_FALSE(descriptor.has_value()); + EXPECT_NE(descriptor.error().find("resolved from dependency"), std::string::npos) << descriptor.error(); +} +#endif + +TEST_F(PluginCatalogTest, UnicodeExtensionPathLoadsOnWindows) { + const std::filesystem::path unicode_dir = dir_ / std::filesystem::path(u8"插件-π"); + std::filesystem::create_directories(unicode_dir); + const std::filesystem::path plugin_path = unicode_dir / pluginFileName("unicode_plugin"); + std::filesystem::copy_file(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH, plugin_path); + + auto descriptor = inspectPluginDso(plugin_path); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "mock-data-source"); + + auto library = DataSourceLibrary::load(plugin_path); + ASSERT_TRUE(library.has_value()) << library.error(); + EXPECT_TRUE(library->valid()); +} + +TEST_F(PluginCatalogTest, AlreadyOpenHandleSupportsInspectionLoadingAndFamilyQuery) { + const std::filesystem::path plugin_path = PJ_MOCK_DATA_SOURCE_PLUGIN_PATH; + auto raw_handle = detail::loadLibraryHandle(plugin_path); + ASSERT_TRUE(raw_handle.has_value()) << raw_handle.error(); + auto owner = detail::adoptLibraryHandle(*raw_handle); + auto shared_handle = detail::adoptLibraryHandleNonOwning(owner.get()); + + const auto families = detail::exportedPluginFamilies(shared_handle, plugin_path); + EXPECT_EQ(families, std::vector{PluginFamily::kDataSource}); + + auto descriptor = inspectPluginDso(shared_handle, plugin_path); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "mock-data-source"); + + auto library = DataSourceLibrary::loadFromHandle(shared_handle, plugin_path); + ASSERT_TRUE(library.has_value()) << library.error(); + EXPECT_TRUE(library->valid()); +} + +TEST_F(PluginCatalogTest, DependencySearchExcludesCwdAndPath) { + const std::filesystem::path candidate_dir = dir_ / "candidate"; + const std::filesystem::path cwd_decoy_dir = dir_ / "cwd-decoy"; + const std::filesystem::path path_decoy_dir = dir_ / "path-decoy"; + std::filesystem::create_directories(candidate_dir); + std::filesystem::create_directories(cwd_decoy_dir); + std::filesystem::create_directories(path_decoy_dir); + + const std::filesystem::path candidate_source = PJ_DEPENDENCY_SEARCH_CANDIDATE_PATH; + const std::filesystem::path real_source = PJ_DEPENDENCY_SEARCH_REAL_PATH; + const std::filesystem::path decoy_source = PJ_DEPENDENCY_SEARCH_DECOY_PATH; + const std::filesystem::path candidate = candidate_dir / candidate_source.filename(); + const std::filesystem::path sibling = candidate_dir / real_source.filename(); + std::filesystem::copy_file(candidate_source, candidate); + std::filesystem::copy_file(real_source, sibling); + std::filesystem::copy_file(decoy_source, cwd_decoy_dir / real_source.filename()); + std::filesystem::copy_file(decoy_source, path_decoy_dir / real_source.filename()); + + const std::filesystem::path original_cwd = std::filesystem::current_path(); +#if defined(_WIN32) + std::optional old_path; + const DWORD old_path_size = GetEnvironmentVariableW(L"PATH", nullptr, 0); + if (old_path_size > 0) { + std::wstring value(old_path_size, L'\0'); + const DWORD copied = GetEnvironmentVariableW(L"PATH", value.data(), old_path_size); + ASSERT_GT(copied, 0U); + value.resize(copied); + old_path = std::move(value); + } + ASSERT_NE(SetEnvironmentVariableW(L"PATH", path_decoy_dir.c_str()), 0); +#endif + std::filesystem::current_path(cwd_decoy_dir); + + auto sibling_result = inspectPluginDso(candidate); + std::filesystem::remove(sibling); + auto decoy_only_result = DataSourceLibrary::load(candidate); + + std::filesystem::current_path(original_cwd); +#if defined(_WIN32) + ASSERT_NE(SetEnvironmentVariableW(L"PATH", old_path.has_value() ? old_path->c_str() : nullptr), 0); +#endif + + ASSERT_TRUE(sibling_result.has_value()) << sibling_result.error(); + EXPECT_EQ(sibling_result->id, "dependency-search-real"); + EXPECT_FALSE(decoy_only_result.has_value()); +} + TEST_F(PluginCatalogTest, MissingIdManifestIsRejected) { auto descriptor = inspectPluginDso(PJ_MISSING_ID_PLUGIN_PATH); ASSERT_FALSE(descriptor.has_value()); diff --git a/pj_plugins/tests/source_dialog_integration_test.cpp b/pj_plugins/tests/source_dialog_integration_test.cpp index 6ecc714c..7a56e5b6 100644 --- a/pj_plugins/tests/source_dialog_integration_test.cpp +++ b/pj_plugins/tests/source_dialog_integration_test.cpp @@ -3,10 +3,14 @@ #include +#include +#include +#include #include #include #include +#include "detail/library_loader.hpp" #include "pj_plugins/host/config_envelope.hpp" #include "pj_plugins/host/data_source_library.hpp" #include "pj_plugins/host/dialog_handle.hpp" @@ -21,6 +25,46 @@ namespace { +class TemporaryDirectory { + public: + TemporaryDirectory() + : path_( + std::filesystem::temp_directory_path() / + ("pj_dialog_loader_" + + std::to_string(static_cast(std::chrono::steady_clock::now().time_since_epoch().count())))) { + std::filesystem::create_directories(path_); + } + + ~TemporaryDirectory() { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + + const std::filesystem::path& path() const noexcept { + return path_; + } + + private: + std::filesystem::path path_; +}; + +class CurrentPathGuard { + public: + CurrentPathGuard() : original_(std::filesystem::current_path()) {} + + ~CurrentPathGuard() { + std::error_code error; + std::filesystem::current_path(original_, error); + } + + const std::filesystem::path& original() const noexcept { + return original_; + } + + private: + std::filesystem::path original_; +}; + // --- Test 1: Load combined .so --- TEST(SourceDialogIntegration, LoadCombinedPlugin) { @@ -51,6 +95,72 @@ TEST(SourceDialogIntegration, ResolveDialogVtable) { EXPECT_EQ((*dialog_vt)->protocol_version, PJ_DIALOG_PROTOCOL_VERSION); } +TEST(SourceDialogIntegration, DialogVtableSurvivesCandidateFileDeletion) { + TemporaryDirectory temporary; + const std::filesystem::path plugin_filename = + std::filesystem::path(PJ_MOCK_SOURCE_WITH_DIALOG_PLUGIN_PATH).filename(); + const std::filesystem::path candidate = temporary.path() / plugin_filename; + std::filesystem::copy_file(PJ_MOCK_SOURCE_WITH_DIALOG_PLUGIN_PATH, candidate); + + { + auto lib = PJ::DataSourceLibrary::load(candidate); + ASSERT_TRUE(lib) << lib.error(); +#if defined(_WIN32) + // A mapped image cannot be deleted on Windows, but it can be renamed away; + // deferred resolution must not depend on the original path either way. + std::filesystem::rename(candidate, candidate.parent_path() / "moved-away.dll"); +#else + ASSERT_TRUE(std::filesystem::remove(candidate)); +#endif + + auto dialog_vtable = lib->resolveDialogVtable(); + ASSERT_TRUE(dialog_vtable) << dialog_vtable.error(); + EXPECT_EQ((*dialog_vtable)->protocol_version, PJ_DIALOG_PROTOCOL_VERSION); + } + +#if !defined(_WIN32) + const std::filesystem::path real_directory = temporary.path() / "real"; + const std::filesystem::path symlink_directory = temporary.path() / "symlink"; + std::filesystem::create_directories(real_directory); + std::filesystem::create_directory_symlink(real_directory, symlink_directory); + const std::filesystem::path real_candidate = real_directory / plugin_filename; + const std::filesystem::path symlink_candidate = symlink_directory / plugin_filename; + std::filesystem::copy_file(PJ_MOCK_SOURCE_WITH_DIALOG_PLUGIN_PATH, real_candidate); + + // Preload the real spelling so glibc reuses a link-map whose dli_fname is the + // canonical path when the library API subsequently loads through the symlink. + // This reproduces dyld's realpath reporting on Linux. + auto preloaded_handle = PJ::detail::loadLibraryHandle(real_candidate); + ASSERT_TRUE(preloaded_handle) << preloaded_handle.error(); + auto preloaded_owner = PJ::detail::adoptLibraryHandle(*preloaded_handle); + + auto symlink_lib = PJ::DataSourceLibrary::load(symlink_candidate); + ASSERT_TRUE(symlink_lib) << symlink_lib.error(); + ASSERT_TRUE(std::filesystem::remove(real_candidate)); + + auto symlink_dialog_vtable = symlink_lib->resolveDialogVtable(); + ASSERT_TRUE(symlink_dialog_vtable) << symlink_dialog_vtable.error(); + EXPECT_EQ((*symlink_dialog_vtable)->protocol_version, PJ_DIALOG_PROTOCOL_VERSION); +#endif +} + +TEST(SourceDialogIntegration, DialogVtableSurvivesCwdChangeAfterRelativeLoad) { + TemporaryDirectory temporary; + const std::filesystem::path candidate = + temporary.path() / std::filesystem::path(PJ_MOCK_SOURCE_WITH_DIALOG_PLUGIN_PATH).filename(); + std::filesystem::copy_file(PJ_MOCK_SOURCE_WITH_DIALOG_PLUGIN_PATH, candidate); + + CurrentPathGuard current_path; + std::filesystem::current_path(temporary.path()); + auto lib = PJ::DataSourceLibrary::load(candidate.filename()); + std::filesystem::current_path(current_path.original()); + + ASSERT_TRUE(lib) << lib.error(); + auto dialog_vtable = lib->resolveDialogVtable(); + ASSERT_TRUE(dialog_vtable) << dialog_vtable.error(); + EXPECT_EQ((*dialog_vtable)->protocol_version, PJ_DIALOG_PROTOCOL_VERSION); +} + // --- Test 4: Borrowed dialog context --- TEST(SourceDialogIntegration, BorrowedDialogContext) {