Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.22.0
0.23.0
10 changes: 10 additions & 0 deletions cmake/PjPluginManifest.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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
$<$<PLATFORM_ID:Linux>:-Wl,-Bsymbolic-functions>
)
Expand Down
107 changes: 104 additions & 3 deletions pj_plugins/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -367,6 +451,7 @@ target_compile_definitions(source_dialog_integration_test PRIVATE
PJ_MOCK_DATA_SOURCE_PLUGIN_PATH="$<TARGET_FILE:mock_data_source_plugin>"
)
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
)
Expand Down Expand Up @@ -565,17 +650,33 @@ target_compile_definitions(plugin_catalog_test PRIVATE
PJ_MISSING_ID_PLUGIN_PATH="$<TARGET_FILE:missing_id_data_source_plugin>"
PJ_INVALID_OPTIONAL_PLUGIN_PATH="$<TARGET_FILE:invalid_optional_manifest_data_source_plugin>"
PJ_MISSING_REQUIRED_SLOTS_PLUGIN_PATH="$<TARGET_FILE:missing_required_slots_plugin>"
)
PJ_ENTRY_POINT_VIA_DEPENDENCY_PLUGIN_PATH="$<TARGET_FILE:entry_point_via_dependency_plugin>"
PJ_ENTRY_POINT_WITH_OWN_EXPORTS_PLUGIN_PATH="$<TARGET_FILE:entry_point_with_own_exports_plugin>"
PJ_DEPENDENCY_SEARCH_CANDIDATE_PATH="$<TARGET_FILE:dependency_search_candidate_plugin>"
PJ_DEPENDENCY_SEARCH_REAL_PATH="$<TARGET_FILE:dependency_search_real>"
PJ_DEPENDENCY_SEARCH_DECOY_PATH="$<TARGET_FILE:dependency_search_decoy>"
)
if(WIN32)
target_compile_definitions(plugin_catalog_test PRIVATE
PJ_ENTRY_POINT_FORWARDER_PLUGIN_PATH="$<TARGET_FILE:entry_point_forwarder_plugin>"
)
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include <pj_plugins/dialog_protocol.h>

#include <filesystem>
#include <memory>
#include <pj_plugins/host/dialog_handle.hpp>
#include <string>
Expand Down Expand Up @@ -32,6 +33,25 @@ class DialogLibrary {
/// Load a dialog plugin from @p path. Returns an error string on failure.
[[nodiscard]] static Expected<DialogLibrary> load(std::string_view path);

/// Preserve an unambiguous load call for existing narrow string paths.
[[nodiscard]] static Expected<DialogLibrary> load(const char* path) {
return load(std::string_view(path));
}

/// Preserve an unambiguous load call for existing `std::string` paths.
[[nodiscard]] static Expected<DialogLibrary> load(const std::string& path) {
return load(std::string_view(path));
}

/// Load a dialog plugin from a filesystem-native @p path.
[[nodiscard]] static Expected<DialogLibrary> 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<DialogLibrary> loadFromHandle(
std::shared_ptr<void> 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;
Expand Down
25 changes: 20 additions & 5 deletions pj_plugins/dialog_protocol/src/dialog_library.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,32 @@ DialogLibrary& DialogLibrary::operator=(DialogLibrary&& other) noexcept {
}

Expected<DialogLibrary> DialogLibrary::load(std::string_view path) {
auto raw_handle = detail::loadLibraryHandle(path);
return load(std::filesystem::path(path));
}

Expected<DialogLibrary> 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> DialogLibrary::loadFromHandle(
std::shared_ptr<void> 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());
}
Expand All @@ -64,7 +79,7 @@ Expected<DialogLibrary> 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() {
Expand Down
26 changes: 23 additions & 3 deletions pj_plugins/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
30 changes: 29 additions & 1 deletion pj_plugins/include/pj_plugins/host/data_source_library.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <pj_base/data_source_protocol.h>
#include <pj_plugins/dialog_protocol.h>

#include <filesystem>
#include <memory>
#include <pj_plugins/host/data_source_handle.hpp>
#include <string>
Expand All @@ -30,6 +31,10 @@

namespace PJ {

namespace detail {
struct LibraryPathIdentity;
}

/**
* Loads a DataSource plugin shared library and provides factory access.
*
Expand All @@ -51,6 +56,25 @@ class DataSourceLibrary {
/// Load a plugin from @p path. Returns an error string on failure.
[[nodiscard]] static Expected<DataSourceLibrary> load(std::string_view path);

/// Preserve an unambiguous load call for existing narrow string paths.
[[nodiscard]] static Expected<DataSourceLibrary> load(const char* path) {
return load(std::string_view(path));
}

/// Preserve an unambiguous load call for existing `std::string` paths.
[[nodiscard]] static Expected<DataSourceLibrary> load(const std::string& path) {
return load(std::string_view(path));
}

/// Load a plugin from a filesystem-native @p path.
[[nodiscard]] static Expected<DataSourceLibrary> 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<DataSourceLibrary> loadFromHandle(
std::shared_ptr<void> 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<DataSourceLibrary> loadStatic(
Expand Down Expand Up @@ -80,8 +104,11 @@ class DataSourceLibrary {
}

private:
[[nodiscard]] static Expected<DataSourceLibrary> loadFromHandleWithIdentity(
std::shared_ptr<void> handle, const detail::LibraryPathIdentity& origin);

DataSourceLibrary(
std::shared_ptr<void> handle, const PJ_data_source_vtable_t* vtable, std::string path,
std::shared_ptr<void> 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();
Expand All @@ -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
Loading
Loading