From d9f8460a4e5088c851fb64da2fad5ee09646ac1a Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 16 Jul 2026 20:40:32 +0800 Subject: [PATCH] fix: merge PythonFileUtils into PluginFsUtils --- resources/web/dialog/PluginsDialog/index.js | 2 +- src/slic3r/CMakeLists.txt | 2 - src/slic3r/GUI/GUI_App.cpp | 2 +- src/slic3r/GUI/PluginsDialog.cpp | 56 +- src/slic3r/GUI/PluginsDialog.hpp | 8 +- src/slic3r/plugin/PluginAuditManager.cpp | 16 +- src/slic3r/plugin/PluginAuditManager.hpp | 19 +- src/slic3r/plugin/PluginFsUtils.cpp | 954 ++++++++++++++++- src/slic3r/plugin/PluginFsUtils.hpp | 43 + src/slic3r/plugin/PluginLoader.cpp | 1 - src/slic3r/plugin/PluginManager.cpp | 22 +- src/slic3r/plugin/PluginManager.hpp | 4 +- src/slic3r/plugin/PythonFileUtils.cpp | 958 ------------------ src/slic3r/plugin/PythonFileUtils.hpp | 61 -- src/slic3r/plugin/PythonInterpreter.cpp | 2 +- ...cingPipelinePluginCapabilityTrampoline.hpp | 4 +- tests/slic3rutils/test_plugin_install.cpp | 2 +- tests/slic3rutils/test_plugin_lifecycle.cpp | 2 +- 18 files changed, 1051 insertions(+), 1107 deletions(-) delete mode 100644 src/slic3r/plugin/PythonFileUtils.cpp delete mode 100644 src/slic3r/plugin/PythonFileUtils.hpp diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 238de8ec9a..e75841a807 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -1071,7 +1071,7 @@ function RunScriptPlugin(pluginId, capabilityName = "") { if (!plugin || !capability) return; - SendMessage("run_script_plugin", { + SendMessage("run_script_plugin_capability", { plugin_key: pluginId, capability_name: String(capability.name || "") }); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index d0c2360eee..87d2b6b81c 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -594,8 +594,6 @@ set(SLIC3R_GUI_SOURCES GUI/wxExtensions.hpp plugin/PythonInterpreter.cpp plugin/PythonInterpreter.hpp - plugin/PythonFileUtils.cpp - plugin/PythonFileUtils.hpp plugin/PythonPluginBridge.cpp plugin/PythonPluginBridge.hpp plugin/PythonPluginInterface.hpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index bb9e9010af..901b653a5a 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4775,7 +4775,7 @@ void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PRO remove_user_presets(); enable_user_preset_folder(false); Slic3r::PluginManager::instance().unload_cloud_plugins(); - Slic3r::PluginManager::instance().clear_cloud_plugin_catalog(); + Slic3r::PluginManager::instance().clear_cloud_plugin_metadata(); Slic3r::PluginManager::instance().set_cloud_user(""); preset_bundle->load_user_presets(DEFAULT_USER_FOLDER_NAME, ForwardCompatibilitySubstitutionRule::Enable); mainframe->update_side_preset_ui(); diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index b0af572f20..95ff99c822 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -121,8 +121,8 @@ struct PluginDialogItem PluginAvailableActions available_actions; }; -constexpr bool kFetchCloudCatalog = true; -constexpr bool kUseCurrentCloudCatalog = false; +constexpr bool kFetchCloudMeta = true; +constexpr bool kUseCurrentCloudMeta = false; // The type of a package's first loaded capability. A package that is not loaded has no type. PluginCapabilityType primary_capability_type_of(PluginManager& manager, const std::string& plugin_key) @@ -131,7 +131,7 @@ PluginCapabilityType primary_capability_type_of(PluginManager& manager, const st return capabilities.empty() ? PluginCapabilityType::Unknown : capabilities.front()->type(); } -std::vector current_cloud_catalog_snapshot() +std::vector current_cloud_metadata_snapshot() { std::vector cloud_entries; for (const PluginDescriptor& entry : PluginManager::instance().get_plugin_descriptors(/*include_invalid=*/true)) @@ -152,21 +152,18 @@ PluginDescriptor as_cloud_only_descriptor(PluginDescriptor descriptor) return descriptor; } -// rescan_plugins() clears the whole catalog and rediscovers only local packages. When -// callers do not need fresh cloud data, reuse the current cloud rows after the local -// rescan so cloud-only rows and cloud-derived UI state do not disappear. -void refresh_plugin_catalog_blocking(bool fetch_cloud) +void refresh_plugin_metadata_blocking(bool fetch_cloud) { PluginManager& manager = PluginManager::instance(); std::vector not_found, unauthorized; - const std::vector current_cloud_catalog = fetch_cloud ? std::vector{} : - current_cloud_catalog_snapshot(); + const std::vector current_cloud_metadata = fetch_cloud ? std::vector{} : + current_cloud_metadata_snapshot(); manager.rescan_plugins(); if (!fetch_cloud) { - manager.update_cloud_catalog(current_cloud_catalog); + manager.update_cloud_metadata(current_cloud_metadata); return; } @@ -452,7 +449,7 @@ void PluginsDialog::set_open_terminal_dlg_fn() void PluginsDialog::update_plugin_dialog_ui() { - // Called after the shared catalog is already updated, for example from the + // Called after the any metadata is already updated, for example from the // cloud-plugin state callback. Do not fetch here or the callback can re-enter. send_plugins(); resolve_pending_activation(); @@ -486,10 +483,6 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) const std::string command = payload.value("command", ""); if (command == "request_plugins") { - // The web page finished loading and is asking for the current catalog. Plugin - // discovery already runs at startup and on login, so the shared catalog is up to - // date by the time the dialog opens. Just render it here - no blocking fetch on - // open. The Refresh button (refresh_plugins) is what triggers a fresh discovery. send_plugins(); } else if (command == "refresh_plugins") { refresh_plugins(); @@ -502,8 +495,8 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) install_plugin_from_file(); } else if (command == "plugin_menu_action") { handle_plugin_menu_action(payload.value("plugin_key", ""), payload.value("action", "")); - } else if (command == "run_script_plugin") { - run_script_plugin(payload.value("plugin_key", ""), payload.value("capability_name", "")); + } else if (command == "run_script_plugin_capability") { + run_script_plugin_capability(payload.value("plugin_key", ""), payload.value("capability_name", "")); } else if (command == "open_terminal") { m_open_terminal_dlg_fn(); } else if (command == "update_plugin") { @@ -574,16 +567,16 @@ std::shared_ptr PluginsDialog::get_capability(const s return PluginManager::instance().get_plugin_capability(plugin_key, capability_name, type, /*only_enabled=*/false); } -void PluginsDialog::refresh_plugin_catalog_async(const wxString& title, const wxString& message, bool fetch_cloud) +void PluginsDialog::refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud) { - run_with_dialog([fetch_cloud]() { refresh_plugin_catalog_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message); + run_with_dialog([fetch_cloud]() { refresh_plugin_metadata_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message); } void PluginsDialog::refresh_plugins() { BOOST_LOG_TRIVIAL(info) << "Refreshing plugins from Plugins dialog"; - refresh_plugin_catalog_async(_L("Refreshing"), _L("Refreshing plugins data"), kFetchCloudCatalog); + refresh_plugin_metadata_async(_L("Refreshing"), _L("Refreshing plugins data"), kFetchCloudMeta); } void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) @@ -595,9 +588,6 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) PluginDescriptor row_data; if (!get_descriptor(plugin_key, row_data)) { - // The row no longer maps to a catalog entry (the catalog changed under the UI). - // Toggling is a local action, so just re-render from the current catalog; a cloud - // fetch (or clearing rescan) adds nothing here. send_plugins(); return; } @@ -688,9 +678,6 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug PluginDescriptor row_data; if (!get_descriptor(plugin_key, row_data)) { - // The row no longer maps to a catalog entry (the catalog changed under the UI). - // Toggling is a local action, so just re-render from the current catalog; a cloud - // fetch (or clearing rescan) adds nothing here. send_plugins(); return; } @@ -829,7 +816,7 @@ bool PluginsDialog::install_plugin_package(const std::string& package_path) BOOST_LOG_TRIVIAL(info) << "Plugin package installed successfully from " << package_path; const wxString installed_name = from_u8(plugin_descriptor.name.empty() ? package_file.filename().string() : plugin_descriptor.name); show_status(wxString::Format(_L("Installed \"%s\"."), installed_name), "success"); - refresh_plugin_catalog_async(_L("Refreshing"), _L("Refreshing plugins data"), kUseCurrentCloudCatalog); + refresh_plugin_metadata_async(_L("Refreshing"), _L("Refreshing plugins data"), kUseCurrentCloudMeta); return true; } @@ -883,9 +870,8 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const return from_u8(plugin_key); } -void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name) +void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name) { - PluginManager& manager = PluginManager::instance(); std::string error; @@ -1028,11 +1014,11 @@ void PluginsDialog::delete_local_plugin(const PluginDescriptor& plugin) auto state = std::make_shared(); run_with_dialog( - [plugin_key = plugin.plugin_key, refresh_catalog = plugin.is_cloud_plugin(), state]() { + [plugin_key = plugin.plugin_key, should_refresh = plugin.is_cloud_plugin(), state]() { std::string error; const bool succeeded = PluginManager::instance().delete_plugin(plugin_key, error); - if (succeeded && refresh_catalog) - refresh_plugin_catalog_blocking(kFetchCloudCatalog); + if (succeeded && should_refresh) + refresh_plugin_metadata_blocking(kFetchCloudMeta); store_plugin_operation_result(state, succeeded, std::move(error)); }, [this, state, plugin_name]() { @@ -1137,7 +1123,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin) return; } - manager.update_cloud_catalog(std::vector{as_cloud_only_descriptor(plugin)}); + manager.update_cloud_metadata(std::vector{as_cloud_only_descriptor(plugin)}); } if (!install_cloud_plugin(plugin_key, plugin.version, from_u8(plugin.name))) { @@ -1195,10 +1181,6 @@ void PluginsDialog::delete_mine_local_and_cloud_plugin(const std::string& plugin return; } - // delete_mine_local_and_cloud_plugin already updated the in-memory catalog - // (finalize_cloud_plugin_removal removes the row and, when a local package existed, - // re-syncs the cloud list itself), so a UI refresh is sufficient here - an extra - // clearing rescan + cloud fetch would be redundant. send_plugins(); show_status(wxString::Format(_L("Deleted \"%s\"."), plugin_name), "success"); } diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 49049d6982..52970443fb 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -60,7 +60,7 @@ private: bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const; std::shared_ptr get_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) const; - void refresh_plugin_catalog_async(const wxString& title, const wxString& message, bool fetch_cloud); + void refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud); void refresh_plugins(); void toggle_plugin(const std::string& plugin_key, bool enabled); void toggle_plugin_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name, bool enabled); @@ -69,7 +69,7 @@ private: void install_plugin_from_file(); bool install_plugin_package(const std::string& package_path); bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name); - void run_script_plugin(const std::string& plugin_key, const std::string& capability_name); + void run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name); // Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"), // used for every plugin/capability operation instead of a modal box so the dialog stays non-disruptive. void show_status(const wxString& message, const char* level); @@ -234,9 +234,9 @@ private: PluginSortKey m_plugin_sort_key = PluginSortKey::None; PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc; - // Serializes run_script_plugin. With main-thread execution a plugin's orca.host.ui modal + // Serializes run_script_plugin_capability. With main-thread execution a plugin's orca.host.ui modal // (message/show_dialog) or the result message box pumps a nested event loop, which could - // re-dispatch the web "run_script_plugin" command and start a second, overlapping run. + // re-dispatch the web "run_script_plugin_capability" command and start a second, overlapping run. bool m_script_running = false; // Plugin whose asynchronous activation is in flight, awaited by resolve_pending_activation(). diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 40c16a4807..d64e94a968 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -14,11 +14,11 @@ namespace Slic3r { // Path safety // --------------------------------------------------------------------------- -bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::filesystem::path& allowed_root) +bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root) { - namespace fs = std::filesystem; + namespace fs = boost::filesystem; - std::error_code ec; + boost::system::error_code ec; // Canonicalize both paths. weakly_canonical resolves symlinks but does // NOT require the path to exist — it canonicalizes the prefix that exists @@ -78,7 +78,7 @@ bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::f thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading; -thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local AuditViolation PluginAuditManager::m_last_violation; @@ -115,7 +115,7 @@ std::string PluginAuditManager::current_plugin() const { return m_current_plugin void PluginAuditManager::clear_current_plugin() { m_current_plugin_key.clear(); } -void PluginAuditManager::add_global_allowed_root(const std::filesystem::path& root) +void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root) { if (root.empty()) return; @@ -125,7 +125,7 @@ void PluginAuditManager::add_global_allowed_root(const std::filesystem::path& ro BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string(); } -void PluginAuditManager::add_scoped_allowed_root(const std::filesystem::path& root) +void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root) { if (root.empty()) return; @@ -163,12 +163,12 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const return {true, ""}; } - namespace fs = std::filesystem; + namespace fs = boost::filesystem; fs::path candidate(path_str); // Resolve relative paths against the current working directory if (candidate.is_relative()) { - std::error_code ec; + boost::system::error_code ec; candidate = fs::absolute(candidate, ec); if (ec) candidate = fs::path(path_str); diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 713eeb09cc..659ecdc892 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -2,11 +2,12 @@ #define slic3r_PluginAuditManager_hpp_ #include -#include #include #include #include +#include + namespace Slic3r { struct AuditDecision { @@ -17,14 +18,14 @@ struct AuditDecision { struct AuditViolation { std::string plugin_key; std::string event_name; - std::filesystem::path path; + boost::filesystem::path path; std::string reason; }; // Returns true if candidate resolves to a path inside allowed_root. // Uses weakly_canonical and component-wise comparison to reject traversal attacks. -bool is_inside_allowed_root(const std::filesystem::path& candidate, - const std::filesystem::path& allowed_root); +bool is_inside_allowed_root(const boost::filesystem::path& candidate, + const boost::filesystem::path& allowed_root); class PluginAuditManager { @@ -40,8 +41,8 @@ public: void clear_current_plugin(); // --- allowed-roots registry --- - void add_global_allowed_root(const std::filesystem::path& root); - void add_scoped_allowed_root(const std::filesystem::path& root); + void add_global_allowed_root(const boost::filesystem::path& root); + void add_scoped_allowed_root(const boost::filesystem::path& root); // --- enforcement mode --- enum class AuditMode { @@ -76,12 +77,12 @@ private: static thread_local std::string m_current_plugin_key; static thread_local AuditMode m_audit_mode; - static thread_local std::vector m_scoped_allowed_roots; + static thread_local std::vector m_scoped_allowed_roots; static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; std::mutex m_mutex; - std::vector m_global_allowed_roots; + std::vector m_global_allowed_roots; }; // RAII guard that sets the current plugin key and restores the previous one. @@ -100,7 +101,7 @@ public: private: std::string m_previous_id; PluginAuditManager::AuditMode m_previous_mode; - std::vector m_previous_scoped_roots; + std::vector m_previous_scoped_roots; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index b9685a4764..7852bce6e4 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -1,16 +1,31 @@ #include "PluginFsUtils.hpp" -#include "PythonFileUtils.hpp" #include "libslic3r/Utils.hpp" +#include "libslic3r/miniz_extension.hpp" #include #include +#include + +#include "PluginAuditManager.hpp" +#include "PythonInterpreter.hpp" + +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include -#include "PluginAuditManager.hpp" +#ifdef WIN32 +#include +#endif namespace Slic3r { @@ -47,8 +62,8 @@ bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, return false; for (const auto& allowed_dir : allowed_dirs) { - if (is_inside_allowed_root(std::filesystem::path(resolved_root.string()), - std::filesystem::path(allowed_dir))) + if (is_inside_allowed_root(boost::filesystem::path(resolved_root.string()), + boost::filesystem::path(allowed_dir))) return true; } @@ -253,5 +268,936 @@ std::vector discover_plugin_packages(const std::vector(static_cast(p[2])) | + static_cast(static_cast(p[3]) << 8); + if (p[0] == '\x75' && p[1] == '\x70' && len >= 5 && p + 4 + len <= e && p[4] == '\x01') + return std::string(p + 9, p + 4 + len); + p += 4 + len; + } + + return decode_path(fallback.c_str()); +} + +std::string zip_entry_name(mz_zip_archive& archive, const mz_zip_archive_file_stat& stat) +{ + if (stat.m_is_utf8) + return stat.m_filename; + + std::string extra(1024, 0); + const size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); + return decode_zip_entry_extra_path(extra.substr(0, n), stat.m_filename); +} + +std::string normalize_zip_entry_name(std::string entry_name) +{ + std::replace(entry_name.begin(), entry_name.end(), '\\', '/'); + while (!entry_name.empty() && entry_name.back() == '/') + entry_name.pop_back(); + return entry_name; +} + +struct ZipReaderGuard +{ + mz_zip_archive archive; + bool opened = false; + + ZipReaderGuard() { mz_zip_zero_struct(&archive); } + + ~ZipReaderGuard() + { + if (opened) + close_zip_reader(&archive); + } +}; + +} // namespace + +bool is_valid_plugin_id(const std::string& id) +{ + if (id.empty()) + return false; + if (id == "." || id == ".." || id[0] == '.' || id.rfind("__", 0) == 0) + return false; + + for (unsigned char ch : id) { + if (std::isalnum(ch) || ch == '_' || ch == '-' || ch == '.') + continue; + return false; + } + + return true; +} + +namespace { + +// RAII helper to free heap-allocated memory from miniz. +struct MzHeapFree { + void* ptr = nullptr; + ~MzHeapFree() { if (ptr) std::free(ptr); } +}; + +// Read a text file from within a zip archive into a string. +// Returns true on success, false if the file is not found or cannot be read. +bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::string& out, std::string& error) +{ + size_t size = 0; + void* data = mz_zip_reader_extract_file_to_heap(&archive, filename, &size, 0); + if (!data) { + error = std::string("Wheel does not contain ") + filename; + return false; + } + MzHeapFree guard{data}; + out.assign(static_cast(data), size); + return true; +} + +// TOML section parsing states. +enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; + +// Strip a quoted string value: "foo" → foo, 'foo' → foo. +// Returns the unquoted value or the input unchanged if not quoted. +std::string unquote_toml_string(const std::string& val) +{ + if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\''))) + return val.substr(1, val.size() - 2); + return val; +} + +// Split a TOML inline array: ["a", "b"] → {"a", "b"}. +// Handles trailing commas and single-line format. +std::vector parse_toml_inline_array(const std::string& val) +{ + std::vector result; + std::string inner = val; + // Strip outer brackets. + if (!inner.empty() && inner.front() == '[') + inner.erase(0, 1); + if (!inner.empty() && inner.back() == ']') + inner.pop_back(); + + // Simple split by comma, strip quotes and whitespace. + std::istringstream ss(inner); + std::string item; + while (std::getline(ss, item, ',')) { + // Trim whitespace. + size_t s = 0, e = item.size(); + while (s < e && (item[s] == ' ' || item[s] == '\t')) ++s; + while (e > s && (item[e - 1] == ' ' || item[e - 1] == '\t')) --e; + item = item.substr(s, e - s); + if (!item.empty()) + result.push_back(unquote_toml_string(item)); + } + return result; +} + +// Parse PEP 723 TOML subset for dependencies, requires-python, and +// [tool.orcaslicer.plugin] identity fields. +// +// requires-python = ">=3.12" +// dependencies = ["pkg>=1.0", ] +// +// [tool.orcaslicer.plugin] +// id = "my-plugin" +// name = "My Plugin" +// description = "Does things." +// author = "Author" +// version = "1.0.0" +// +// Returns false only on parse errors; missing block is not an error. +bool parse_pep723_toml(const std::string& toml_content, + std::vector& out_deps, + std::string& out_requires_python, + std::string& out_name, + std::string& out_description, + std::string& out_author, + std::string& out_version, + std::map& out_settings, + std::string& error) +{ + out_deps.clear(); + out_requires_python.clear(); + out_name.clear(); + out_description.clear(); + out_author.clear(); + out_version.clear(); + out_settings.clear(); + + TomlSection section = TomlSection::Root; + + std::istringstream stream(toml_content); + std::string line; + + while (std::getline(stream, line)) { + // Trim leading/trailing whitespace. + size_t start = 0; + while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) + ++start; + size_t end = line.size(); + while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t')) + --end; + std::string trimmed = line.substr(start, end - start); + + if (trimmed.empty() || trimmed[0] == '#') + continue; + + // TOML section header. + if (trimmed[0] == '[') { + if (trimmed == "[tool.orcaslicer.plugin]") { + section = TomlSection::OrcaPlugin; + } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { + section = TomlSection::OrcaPluginSettings; // per-plugin params table + } else { + section = TomlSection::Root; // Unknown section — skip. + } + continue; + } + + if (section == TomlSection::InDepsArray) { + if (trimmed == "]") { + section = TomlSection::Root; + continue; + } + std::string val = trimmed; + if (!val.empty() && val.back() == ',') + val.pop_back(); + val = unquote_toml_string(val); + if (!val.empty()) + out_deps.push_back(val); + continue; + } + + // Look for key = value. + size_t eq = trimmed.find('='); + if (eq == std::string::npos) + continue; + + std::string key = trimmed.substr(0, eq); + while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) + key.pop_back(); + + std::string val = trimmed.substr(eq + 1); + while (!val.empty() && (val.front() == ' ' || val.front() == '\t')) + val.erase(0, 1); + // Trim trailing. + while (!val.empty() && (val.back() == ' ' || val.back() == '\t')) + val.pop_back(); + + if (section == TomlSection::Root) { + if (key == "requires-python") { + out_requires_python = unquote_toml_string(val); + } else if (key == "dependencies") { + if (val == "[") { + section = TomlSection::InDepsArray; + } else { + // Inline array: dependencies = ["a", "b"] + out_deps = parse_toml_inline_array(val); + } + } + } else if (section == TomlSection::OrcaPlugin) { + if (key == "name") out_name = unquote_toml_string(val); + else if (key == "description") out_description = unquote_toml_string(val); + else if (key == "author") out_author = unquote_toml_string(val); + else if (key == "version") out_version = unquote_toml_string(val); + } else if (section == TomlSection::OrcaPluginSettings) { + // collect every key as a string; the plugin parses (int/float/...) what it needs. + if (!key.empty()) + out_settings[key] = unquote_toml_string(val); + } + } + + // Check for unclosed arrays. + if (section == TomlSection::InDepsArray) { + error = "PEP 723 metadata: unclosed dependencies array"; + return false; + } + return true; +} + +// Normalize a distribution name to a Python import package name. +// Converts hyphens to underscores and lowercases. +std::string normalize_package_name(const std::string& name) +{ + std::string result; + result.reserve(name.size()); + for (unsigned char ch : name) { + if (ch == '-' || ch == '.') + result += '_'; + else + result += static_cast(std::tolower(ch)); + } + return result; +} + +// Parse METADATA (RFC 822 style) into a flat multimap. +// https://packaging.python.org/en/latest/specifications/core-metadata/ +void parse_metadata_rfc822(const std::string& content, + std::string& out_name, + std::string& out_version, + std::string& out_summary, + std::string& out_author, + std::string& out_requires_python, + std::string& out_import_name, + std::vector& out_requires_dist, + std::string& error) +{ + std::istringstream stream(content); + std::string line; + std::string current_header; + std::string current_value; + + auto flush = [&]() { + if (current_header.empty()) + return; + std::string lower = current_header; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + if (lower == "name") + out_name = current_value; + else if (lower == "version") + out_version = current_value; + else if (lower == "summary") + out_summary = current_value; + else if (lower == "author") + out_author = current_value; + else if (lower == "requires-python") + out_requires_python = current_value; + else if (lower == "import-name") + out_import_name = current_value; + else if (lower == "requires-dist") + out_requires_dist.push_back(current_value); + + current_header.clear(); + current_value.clear(); + }; + + while (std::getline(stream, line)) { + // Blank line after headers marks the start of the body. + if (line.empty() && current_header.empty()) + continue; + if (line.empty()) { + flush(); + // Remaining content is the body (description); stop parsing headers. + break; + } + + // Continuation line. + if (line[0] == ' ' || line[0] == '\t') { + if (!current_value.empty()) + current_value += '\n'; + size_t pos = line.find_first_not_of(" \t"); + current_value += (pos != std::string::npos) ? line.substr(pos) : ""; + continue; + } + + flush(); + + size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + + current_header = line.substr(0, colon); + size_t val_start = colon + 1; + while (val_start < line.size() && (line[val_start] == ' ' || line[val_start] == '\t')) + ++val_start; + current_value = line.substr(val_start); + } + + flush(); +} + +} // namespace + +bool is_ignored_plugin_directory(const boost::filesystem::path& path) +{ + const std::string name = path.filename().string(); + return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; +} + +bool is_safe_relative_path(const boost::filesystem::path& path) +{ + if (path.empty() || path.is_absolute() || path.has_root_directory() || path.has_root_name()) + return false; + + for (const auto& part : path) { + const std::string token = part.string(); + if (token == "..") + return false; + } + + return true; +} + +bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error) +{ + namespace fs = boost::filesystem; + + boost::system::error_code ec; + fs::create_directories(destination, ec); + if (ec) { + error = "Failed to create plugin staging directory: " + ec.message(); + return false; + } + + ZipReaderGuard reader; + if (!open_zip_reader(&reader.archive, zip_path.string())) { + error = "Failed to open plugin zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + reader.opened = true; + + std::unordered_set extracted_entries; + const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); + mz_zip_archive_file_stat stat; + for (mz_uint i = 0; i < num_entries; ++i) { + if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) { + error = "Failed to read plugin zip entry metadata: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + + std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); + if (entry_name.empty()) + continue; + if (entry_name.find(':') != std::string::npos) { + error = "Plugin zip entry contains an invalid path: " + entry_name; + return false; + } + + const fs::path relative_path(entry_name); + if (!is_safe_archive_entry_path(relative_path)) { + error = "Plugin zip entry escapes the plugin package: " + entry_name; + return false; + } + + const std::string relative_key = relative_path.generic_string(); + if (!extracted_entries.insert(relative_key).second) { + error = "Plugin zip contains duplicate entry: " + relative_key; + return false; + } + + const fs::path output_path = destination / relative_path; + if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { + fs::create_directories(output_path, ec); + if (ec) { + error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); + return false; + } + continue; + } + + fs::create_directories(output_path.parent_path(), ec); + if (ec) { + error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); + return false; + } + + if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { + error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); + return false; + } + + const std::string encoded_output_path = encode_path(output_path.string().c_str()); + mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); +#ifdef WIN32 + if (!extracted) { + const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); + extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); + } +#endif + if (!extracted) { + error = "Failed to extract plugin zip entry " + relative_key + ": " + + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + } + + return true; +} + +void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry) +{ + PluginInstallState state; + if (!read_install_state(plugin_dir, state)) + return; + + // The cloud identity and the persisted installed version are read back. plugin_key + // is always derived by the catalog scan (filename for local, the cloud uuid for + // cloud), so it is not read from the sidecar. installed_version is the source of + // truth for a cloud plugin's installed version: it records the version fetched from + // the cloud at install time, independent of the (possibly stale) manifest/PEP723 + // header that scan_directory parses into entry.version. + if (!state.installed_version.empty()) + entry.installed_version = state.installed_version; + if (!state.cloud_uuid.empty()) + entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false}; + + // Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a + // capability has no existence — and so no state — until it is materialized, at which point the + // loader seeds the flag onto the capability itself. + entry.enabled = state.enabled; +} + +bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out) +{ + namespace fs = boost::filesystem; + const fs::path sidecar_path = plugin_dir / ".install_state.json"; + + if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path)) + return false; + + boost::nowide::ifstream f(sidecar_path.string()); + if (!f) + return false; + + try { + nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true); + if (state.is_discarded() || !state.is_object()) + return false; + + PluginInstallState parsed; + if (state.contains("installed_from") && state["installed_from"].is_string()) + parsed.installed_from = state["installed_from"].get(); + if (state.contains("installed_version") && state["installed_version"].is_string()) + parsed.installed_version = state["installed_version"].get(); + if (state.contains("plugin_name") && state["plugin_name"].is_string()) + parsed.plugin_name = state["plugin_name"].get(); + if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) + parsed.cloud_uuid = state["cloud_uuid"].get(); + if (state.contains("enabled") && state["enabled"].is_boolean()) + parsed.enabled = state["enabled"].get(); + + // capabilities is a JSON array of single-key objects {: }. + if (state.contains("capabilities") && state["capabilities"].is_array()) { + for (const auto& item : state["capabilities"]) { + if (!item.is_object()) + continue; + for (auto it = item.begin(); it != item.end(); ++it) { + if (it.value().is_boolean()) + parsed.capabilities.emplace_back(it.key(), it.value().get()); + } + } + } + + out = std::move(parsed); + return true; + } catch (...) { + return false; + } +} + +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) +{ + namespace fs = boost::filesystem; + const fs::path sidecar_path = plugin_dir / ".install_state.json"; + + nlohmann::json json; + json["installed_from"] = state.installed_from; + json["installed_version"] = state.installed_version; + json["plugin_name"] = state.plugin_name; + json["enabled"] = state.enabled; + if (!state.cloud_uuid.empty()) + json["cloud_uuid"] = state.cloud_uuid; + + nlohmann::json capabilities = nlohmann::json::array(); + for (const auto& [name, enabled] : state.capabilities) + capabilities.push_back(nlohmann::json{{name, enabled}}); + json["capabilities"] = std::move(capabilities); + + boost::nowide::ofstream f(sidecar_path.string()); + if (!f) + return false; + + f << json.dump(2); + return static_cast(f); +} + +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, + const std::vector>& capabilities) +{ + PluginInstallState state; + state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; + // Prefer the descriptor's recorded installed_version (the version fetched from the cloud + // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header + // never overwrites the source-of-truth version. Fall back to the manifest version for + // first-time/local installs where installed_version is not yet populated. + state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version; + state.plugin_name = entry.name; + state.cloud_uuid = entry.cloud_uuid(); + state.enabled = enabled; + state.capabilities = capabilities; + return write_install_state(plugin_dir, state); +} + +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) +{ + // Install-time writer: the package is not loaded, so its capabilities are not known yet and the + // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real + // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) + // overload. + return write_install_state(plugin_dir, entry, true, {}); +} + +bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(py_path) || !fs::is_regular_file(py_path)) { + error = "Python plugin file does not exist: " + py_path.string(); + return false; + } + + boost::nowide::ifstream f(py_path.string()); + if (!f) { + error = "Failed to open Python plugin file: " + py_path.string(); + return false; + } + + // Scan for PEP 723 inline script metadata block. + // The block is delimited by: + // # /// script + // # + // # /// + std::string pep723_content; + bool in_block = false; + std::string line; + + while (std::getline(f, line)) { + // Strip trailing carriage return (Windows line endings). + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + if (!in_block) { + // Look for opening delimiter. + if (line == "# /// script") + in_block = true; + continue; + } + + if (line == "# ///") { + in_block = false; + continue; + } + + // Extract TOML content from the comment line. + // Lines must start with "# " or "#\t" per PEP 723. + if (line.size() >= 2 && line[0] == '#' && (line[1] == ' ' || line[1] == '\t')) + pep723_content += line.substr(2) + "\n"; + else if (line == "#") + pep723_content += "\n"; + // If line doesn't start with "# ", it's still part of the block content + // but we skip it as it doesn't follow the spec. + } + + if (!pep723_content.empty()) { + std::string pep723_error; + std::string requires_python; + std::string pep_name, pep_desc, pep_author, pep_version; + if (!parse_pep723_toml(pep723_content, + descriptor.dependencies, + requires_python, + pep_name, + pep_desc, + pep_author, + pep_version, + descriptor.settings, + pep723_error)) { + error = "Failed to parse PEP 723 metadata: " + pep723_error; + return false; + } + // requires-python is stored but not validated against the bundled Python here. + (void) requires_python; + + // Populate identity fields from the PEP 723 [tool.orcaslicer.plugin] section. + // Cloud metadata overrides these when available; they serve as the local + // source of truth for side-loaded .py plugins and as fallback values. + if (!pep_name.empty()) descriptor.name = sanitize_plugin_name(pep_name); + if (!pep_desc.empty()) descriptor.description = pep_desc; + if (!pep_author.empty()) descriptor.author = pep_author; + if (!pep_version.empty()) descriptor.version = pep_version; + } + + // Validate that required identity fields are present (either from PEP 723 or + // from cloud metadata already set on the manifest by the caller). + // Validation is deferred to the install/discovery layer so cloud metadata + // can fill in gaps. + return true; +} + +bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(whl_path) || !fs::is_regular_file(whl_path)) { + error = "Wheel plugin file does not exist: " + whl_path.string(); + return false; + } + + ZipReaderGuard reader; + if (!open_zip_reader(&reader.archive, whl_path.string())) { + error = "Failed to open wheel as zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + reader.opened = true; + + // Find the single .dist-info directory. + // Scan ALL entries, not just directory entries — some zip writers omit + // explicit directory entries. normalize_zip_entry_name strips trailing + // slashes, so we match ".dist-info" as the last path component of a + // directory entry, and ".dist-info/" embedded in a file path. + const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); + std::string dist_info_dir; + mz_zip_archive_file_stat stat; + + for (mz_uint i = 0; i < num_entries; ++i) { + if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) + continue; + + std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); + if (entry_name.empty()) + continue; + + // Find .dist-info as a path component. + size_t pos = entry_name.find(".dist-info"); + if (pos == std::string::npos) + continue; + + std::string candidate; + if (pos + 10 == entry_name.size()) { + // Directory entry itself (trailing / stripped by normalize). + candidate = entry_name + "/"; + } else if (pos + 10 < entry_name.size() && entry_name[pos + 10] == '/') { + // File inside .dist-info/: name.dist-info/METADATA + candidate = entry_name.substr(0, pos + 11); // include trailing / + } else { + continue; // .dist-info mid-name, not a path component. + } + + if (!dist_info_dir.empty() && candidate != dist_info_dir) { + error = "Wheel contains multiple .dist-info directories: " + dist_info_dir + " and " + candidate; + return false; + } + dist_info_dir = candidate; + } + + if (dist_info_dir.empty()) { + error = "Wheel does not contain a .dist-info directory"; + return false; + } + + // Read METADATA. + const std::string metadata_path = dist_info_dir + "METADATA"; + std::string meta_content; + if (!read_zip_text_file(reader.archive, metadata_path.c_str(), meta_content, error)) + return false; + + std::string meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name; + std::vector requires_dist; + std::string meta_error; + parse_metadata_rfc822(meta_content, meta_name, meta_version, meta_summary, meta_author, + meta_requires_python, meta_import_name, requires_dist, meta_error); + + if (meta_name.empty()) { + error = "Wheel METADATA missing required Name field"; + return false; + } + if (meta_version.empty()) { + error = "Wheel METADATA missing required Version field"; + return false; + } + + // Read WHEEL (verify existence and Wheel-Version). + const std::string wheel_path = dist_info_dir + "WHEEL"; + std::string wheel_content; + if (!read_zip_text_file(reader.archive, wheel_path.c_str(), wheel_content, error)) + return false; + // Verify there's at least a Wheel-Version header line. + if (wheel_content.find("Wheel-Version:") == std::string::npos) { + error = "Wheel WHEEL file missing Wheel-Version header"; + return false; + } + + // Parse and validate wheel platform tags. + { + std::vector wheel_tags; + std::istringstream wstream(wheel_content); + std::string wline; + while (std::getline(wstream, wline)) { + while (!wline.empty() && (wline.back() == '\r' || wline.back() == '\n')) + wline.pop_back(); + if (wline.rfind("Tag:", 0) == 0) { + std::string tag = wline.substr(4); + size_t s = 0, e = tag.size(); + while (s < e && (tag[s] == ' ' || tag[s] == '\t')) ++s; + while (e > s && (tag[e - 1] == ' ' || tag[e - 1] == '\t')) --e; + wheel_tags.push_back(tag.substr(s, e - s)); + } + } + + if (!wheel_tags.empty()) { + bool compatible = false; + const std::string abi_tag = PythonInterpreter::python_abi_tag(); + for (const auto& tag : wheel_tags) { + // Pure Python wheel: py3-none-any or cp312-none-any + if (tag.find("-none-any") != std::string::npos) { + compatible = true; + break; + } + // Platform-specific: check ABI tag matches. + if (tag.find(abi_tag) == 0) { + // Accept if the platform tag matches the current OS. +#ifdef _WIN32 + if (tag.find("-win") != std::string::npos) + compatible = true; +#elif __APPLE__ + if (tag.find("-macosx") != std::string::npos) + compatible = true; +#else + if (tag.find("-linux") != std::string::npos || tag.find("-manylinux") != std::string::npos) + compatible = true; +#endif + } + } + if (!compatible) { + error = "Wheel is incompatible with this platform. Tags: "; + for (size_t i = 0; i < wheel_tags.size(); ++i) { + if (i > 0) error += ", "; + error += wheel_tags[i]; + } + error += "; expected ABI: " + abi_tag; + return false; + } + } + } + + // Read RECORD (verify existence). + const std::string record_path = dist_info_dir + "RECORD"; + std::string record_content; + if (!read_zip_text_file(reader.archive, record_path.c_str(), record_content, error)) + return false; + if (record_content.empty()) { + error = "Wheel RECORD file is empty"; + return false; + } + + // Parse top_level.txt if present. + std::string top_level; + const std::string top_level_path = dist_info_dir + "top_level.txt"; + std::string top_level_content; + if (read_zip_text_file(reader.archive, top_level_path.c_str(), top_level_content, error)) { + // top_level.txt contains one package name per line. + std::istringstream tl_stream(top_level_content); + std::string tl_line; + std::vector top_levels; + while (std::getline(tl_stream, tl_line)) { + while (!tl_line.empty() && (tl_line.back() == '\r' || tl_line.back() == '\n')) + tl_line.pop_back(); + if (!tl_line.empty()) + top_levels.push_back(tl_line); + } + if (top_levels.size() == 1) + top_level = top_levels[0]; + else if (top_levels.size() > 1) { + // Ambiguous: multiple top-level packages. Fall through to Name-based fallback. + } + // Zero entries: leave top_level empty. + } + // If top_level.txt is not found, that's OK — it's optional per the wheel spec. + + // Determine the entry package in priority order. + // 1. Cloud/catalog metadata — handled by caller, not here. + // 2. Core Metadata Import-Name. + // 3. top_level.txt if unambiguous. + // 4. Normalized Name as fallback. + if (!meta_import_name.empty()) { + descriptor.entry_package = meta_import_name; + } else if (!top_level.empty()) { + descriptor.entry_package = top_level; + } else { + descriptor.entry_package = normalize_package_name(meta_name); + } + + descriptor.dependencies = std::move(requires_dist); + + // Populate local identity fallbacks from wheel metadata. + // Cloud metadata will override these when available. + descriptor.name = sanitize_plugin_name(meta_name); + descriptor.version = meta_version; + descriptor.description = meta_summary; + descriptor.author = meta_author; + + return true; +} + +boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { + error = "Plugin directory does not exist: " + plugin_dir.string(); + return {}; + } + + fs::path py_entry; + fs::path whl_entry; + + for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { + if (is_ignored_plugin_directory(it->path())) + continue; + if (!fs::is_regular_file(it->status())) + continue; + + const fs::path ext = it->path().extension(); + if (ext == ".py") { + if (!py_entry.empty()) { + error = "Plugin directory contains multiple .py files: " + py_entry.filename().string() + + " and " + it->path().filename().string(); + return {}; + } + py_entry = it->path(); + } else if (ext == ".whl") { + if (!whl_entry.empty()) { + error = "Plugin directory contains multiple .whl files: " + whl_entry.filename().string() + + " and " + it->path().filename().string(); + return {}; + } + whl_entry = it->path(); + } + } + + if (!py_entry.empty() && !whl_entry.empty()) { + error = "Plugin directory contains both .py and .whl entry files"; + return {}; + } + + if (!py_entry.empty()) + return py_entry; + + if (!whl_entry.empty()) + return whl_entry; + + error = "Plugin directory does not contain a .py or .whl entry file"; + return {}; +} } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index a47f21c65f..cd3f4a6f7e 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -13,6 +13,15 @@ namespace Slic3r { extern const char* const INSTALL_STATE_FILE; +struct PluginInstallState { + std::string installed_from; // "local" | "cloud" + std::string installed_version; + std::string plugin_name; + std::string cloud_uuid; // empty for local + bool enabled = true; + std::vector> capabilities; // name -> enabled, ordered +}; + // Returns the cloud plugin install/scan directory for a given user_id. // Path: {data_dir}/orca_plugins/_subscribed/{user_id}/ std::string get_cloud_plugin_dir(const std::string& user_id); @@ -48,4 +57,38 @@ std::vector get_plugin_directories(const std::string& cloud_user_id // descriptor.enabled. Capabilities are NOT discovered here — a package has none until it is loaded. std::vector discover_plugin_packages(const std::vector& dirs, std::string& error); +bool is_ignored_plugin_directory(const boost::filesystem::path& path); +bool is_safe_relative_path(const boost::filesystem::path& path); +bool is_valid_plugin_id(const std::string& id); +bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error); + +// Read PEP 723 inline script metadata from a .py plugin file. +// Populates metadata.dependencies and local identity fallbacks. +// Returns true on success (including when no PEP 723 block is found — deps will be empty). +bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error); + +// Read wheel metadata from a .whl plugin file (zip archive). +// Reads METADATA, WHEEL, RECORD, and top_level.txt from the .dist-info directory. +// Populates metadata.entry_package, metadata.dependencies, and local identity fallbacks. +// Returns true on success. +bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error); + +// Find the single plugin entry file (.py or .whl) in a directory. +// Ignores __whl_extracted__ and hidden files/dirs. +// Returns the path to the entry file, or an empty path with error set if zero or multiple candidates. +boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error); + +// Canonical writer: emits the .install_state.json schema for the given state. +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); +// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, + const std::vector>& capabilities); +// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); + +// Reads only the cloud identity (uuid) back into the descriptor; plugin_key is always derived. +void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); +// Full read of the sidecar; returns false if there is no/invalid sidecar. +bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out); + } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index e8140ecc48..df0a38c50e 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -6,7 +6,6 @@ #include "PluginManager.hpp" #include "PluginFsUtils.hpp" -#include "PythonFileUtils.hpp" #include "PythonInterpreter.hpp" #include "PythonPluginBridge.hpp" #include "libslic3r/Utils.hpp" diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 6a306b9aec..b0ca443332 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -5,7 +5,6 @@ #include "PluginFsUtils.hpp" #include "PluginHooks.hpp" -#include "PythonFileUtils.hpp" #include "PythonInterpreter.hpp" #include "PythonPluginBridge.hpp" @@ -1267,7 +1266,7 @@ const char* const CLOUD_PLUGIN_NOT_FOUND_ERROR = "Plugin was not found in the cl } // namespace -void PluginManager::update_cloud_catalog(const std::vector& cloud_list) +void PluginManager::update_cloud_metadata(const std::vector& cloud_list) { std::lock_guard lock(m_mutex); @@ -1353,7 +1352,7 @@ void PluginManager::update_cloud_catalog(const std::vector& cl } } -void PluginManager::clear_cloud_plugin_catalog() +void PluginManager::clear_cloud_plugin_metadata() { // Cloud entries may own live Python modules. Unload them before erasing their vector entries, // and do so outside m_mutex because both Python teardown and lifecycle callbacks can re-enter @@ -1369,7 +1368,7 @@ void PluginManager::clear_cloud_plugin_catalog() } }); - BOOST_LOG_TRIVIAL(info) << "Cleared cloud plugin catalog entries"; + BOOST_LOG_TRIVIAL(info) << "Cleared cloud plugin metadata"; } void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_found, std::vector* out_unauthorized) @@ -1394,7 +1393,7 @@ void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_f } } - update_cloud_catalog(cloud_list); + update_cloud_metadata(cloud_list); { std::lock_guard lock(m_mutex); @@ -1458,7 +1457,6 @@ bool PluginManager::subscribe_and_install_cloud_plugin(const std::string& plugin PluginDescriptor descriptor; bool found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); if (!found) { - // The plugin may already be subscribed or owned while the local catalog is stale. fetch_plugins_from_cloud(); found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); } @@ -1544,9 +1542,9 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ std::lock_guard lock(m_mutex); Plugin* entry = find_plugin_locked(plugin_key); if (entry == nullptr) { - error = "Plugin Manifest not found."; + error = "Plugin not found."; BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Cloud plugin " << plugin_key - << " downloaded successfully but failed to update plugin manifest. Manifest not found in catalog."; + << " downloaded successfully but failed to update plugin metadata. Plugin object not found."; } else { entry->descriptor = std::move(updated_descriptor); } @@ -1747,9 +1745,6 @@ bool PluginManager::keep_installed_plugin_as_local(const PluginDescriptor& plugi bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error) { - // Shared by all four cloud-removal entrypoints after the cloud-side request succeeds. Handles - // the common local follow-up of keeping a detached local copy, deleting local files, or - // dropping a cloud-only row from the catalog. if (keep_local && plugin.has_local_package()) { if (!keep_installed_plugin_as_local(plugin, error)) return false; @@ -1760,8 +1755,7 @@ bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin if (plugin.has_local_package()) { if (!delete_installed_plugin_package(plugin, error)) return false; - // Re-sync the cloud catalog so observers/UI see the updated cloud list after the local - // package has been removed. + fetch_plugins_from_cloud(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Deleted local package after cloud removal: " << plugin.plugin_key; return true; @@ -1773,7 +1767,7 @@ bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin [&plugin](const Plugin& entry) { return entry.descriptor.plugin_key == plugin.plugin_key; }), m_plugins.end()); } - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Removed cloud-only plugin from catalog: " << plugin.plugin_key; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Removed cloud-only plugin: " << plugin.plugin_key; return true; } diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 08f2659c16..5b310504a2 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -194,8 +194,8 @@ public: bool clear_plugin_error(const std::string& plugin_key); void fetch_plugins_from_cloud(std::vector* out_not_found = nullptr, std::vector* out_unauthorized = nullptr); - void update_cloud_catalog(const std::vector& cloud_list); - void clear_cloud_plugin_catalog(); + void update_cloud_metadata(const std::vector& cloud_list); + void clear_cloud_plugin_metadata(); bool download_and_install_cloud_plugin(const std::string& plugin_key, const std::string& version, std::string& error); bool subscribe_and_install_cloud_plugin(const std::string& plugin_key, std::string& error); diff --git a/src/slic3r/plugin/PythonFileUtils.cpp b/src/slic3r/plugin/PythonFileUtils.cpp deleted file mode 100644 index 9a9c117f8f..0000000000 --- a/src/slic3r/plugin/PythonFileUtils.cpp +++ /dev/null @@ -1,958 +0,0 @@ -#include "PythonFileUtils.hpp" - -#include "PluginFsUtils.hpp" -#include "PythonInterpreter.hpp" -#include "libslic3r/Utils.hpp" -#include "PluginDescriptor.hpp" -#include "libslic3r/miniz_extension.hpp" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#ifdef WIN32 -#include -#endif - -namespace Slic3r { -namespace { - -bool is_safe_archive_entry_path(const boost::filesystem::path& path) -{ - if (!is_safe_relative_path(path)) - return false; - - for (const auto& part : path) { - const std::string token = part.string(); - if (token.empty() || token == "." || token == "..") - return false; - } - - return true; -} - -std::string decode_zip_entry_extra_path(const std::string& extra, const std::string& fallback) -{ - const char* p = extra.data(); - const char* e = p + extra.length(); - while (p + 4 <= e) { - const auto len = static_cast(static_cast(p[2])) | - static_cast(static_cast(p[3]) << 8); - if (p[0] == '\x75' && p[1] == '\x70' && len >= 5 && p + 4 + len <= e && p[4] == '\x01') - return std::string(p + 9, p + 4 + len); - p += 4 + len; - } - - return decode_path(fallback.c_str()); -} - -std::string zip_entry_name(mz_zip_archive& archive, const mz_zip_archive_file_stat& stat) -{ - if (stat.m_is_utf8) - return stat.m_filename; - - std::string extra(1024, 0); - const size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); - return decode_zip_entry_extra_path(extra.substr(0, n), stat.m_filename); -} - -std::string normalize_zip_entry_name(std::string entry_name) -{ - std::replace(entry_name.begin(), entry_name.end(), '\\', '/'); - while (!entry_name.empty() && entry_name.back() == '/') - entry_name.pop_back(); - return entry_name; -} - -struct ZipReaderGuard -{ - mz_zip_archive archive; - bool opened = false; - - ZipReaderGuard() { mz_zip_zero_struct(&archive); } - - ~ZipReaderGuard() - { - if (opened) - close_zip_reader(&archive); - } -}; - -} // namespace - -bool is_valid_plugin_id(const std::string& id) -{ - if (id.empty()) - return false; - if (id == "." || id == ".." || id[0] == '.' || id.rfind("__", 0) == 0) - return false; - - for (unsigned char ch : id) { - if (std::isalnum(ch) || ch == '_' || ch == '-' || ch == '.') - continue; - return false; - } - - return true; -} - -namespace { - -// RAII helper to free heap-allocated memory from miniz. -struct MzHeapFree { - void* ptr = nullptr; - ~MzHeapFree() { if (ptr) std::free(ptr); } -}; - -// Read a text file from within a zip archive into a string. -// Returns true on success, false if the file is not found or cannot be read. -bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::string& out, std::string& error) -{ - size_t size = 0; - void* data = mz_zip_reader_extract_file_to_heap(&archive, filename, &size, 0); - if (!data) { - error = std::string("Wheel does not contain ") + filename; - return false; - } - MzHeapFree guard{data}; - out.assign(static_cast(data), size); - return true; -} - -// TOML section parsing states. -enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; - -// Strip a quoted string value: "foo" → foo, 'foo' → foo. -// Returns the unquoted value or the input unchanged if not quoted. -std::string unquote_toml_string(const std::string& val) -{ - if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\''))) - return val.substr(1, val.size() - 2); - return val; -} - -// Split a TOML inline array: ["a", "b"] → {"a", "b"}. -// Handles trailing commas and single-line format. -std::vector parse_toml_inline_array(const std::string& val) -{ - std::vector result; - std::string inner = val; - // Strip outer brackets. - if (!inner.empty() && inner.front() == '[') - inner.erase(0, 1); - if (!inner.empty() && inner.back() == ']') - inner.pop_back(); - - // Simple split by comma, strip quotes and whitespace. - std::istringstream ss(inner); - std::string item; - while (std::getline(ss, item, ',')) { - // Trim whitespace. - size_t s = 0, e = item.size(); - while (s < e && (item[s] == ' ' || item[s] == '\t')) ++s; - while (e > s && (item[e - 1] == ' ' || item[e - 1] == '\t')) --e; - item = item.substr(s, e - s); - if (!item.empty()) - result.push_back(unquote_toml_string(item)); - } - return result; -} - -// Parse PEP 723 TOML subset for dependencies, requires-python, and -// [tool.orcaslicer.plugin] identity fields. -// -// requires-python = ">=3.12" -// dependencies = ["pkg>=1.0", ] -// -// [tool.orcaslicer.plugin] -// id = "my-plugin" -// name = "My Plugin" -// description = "Does things." -// author = "Author" -// version = "1.0.0" -// -// Returns false only on parse errors; missing block is not an error. -bool parse_pep723_toml(const std::string& toml_content, - std::vector& out_deps, - std::string& out_requires_python, - std::string& out_name, - std::string& out_description, - std::string& out_author, - std::string& out_version, - std::map& out_settings, - std::string& error) -{ - out_deps.clear(); - out_requires_python.clear(); - out_name.clear(); - out_description.clear(); - out_author.clear(); - out_version.clear(); - out_settings.clear(); - - TomlSection section = TomlSection::Root; - - std::istringstream stream(toml_content); - std::string line; - - while (std::getline(stream, line)) { - // Trim leading/trailing whitespace. - size_t start = 0; - while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) - ++start; - size_t end = line.size(); - while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t')) - --end; - std::string trimmed = line.substr(start, end - start); - - if (trimmed.empty() || trimmed[0] == '#') - continue; - - // TOML section header. - if (trimmed[0] == '[') { - if (trimmed == "[tool.orcaslicer.plugin]") { - section = TomlSection::OrcaPlugin; - } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { - section = TomlSection::OrcaPluginSettings; // per-plugin params table - } else { - section = TomlSection::Root; // Unknown section — skip. - } - continue; - } - - if (section == TomlSection::InDepsArray) { - if (trimmed == "]") { - section = TomlSection::Root; - continue; - } - std::string val = trimmed; - if (!val.empty() && val.back() == ',') - val.pop_back(); - val = unquote_toml_string(val); - if (!val.empty()) - out_deps.push_back(val); - continue; - } - - // Look for key = value. - size_t eq = trimmed.find('='); - if (eq == std::string::npos) - continue; - - std::string key = trimmed.substr(0, eq); - while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) - key.pop_back(); - - std::string val = trimmed.substr(eq + 1); - while (!val.empty() && (val.front() == ' ' || val.front() == '\t')) - val.erase(0, 1); - // Trim trailing. - while (!val.empty() && (val.back() == ' ' || val.back() == '\t')) - val.pop_back(); - - if (section == TomlSection::Root) { - if (key == "requires-python") { - out_requires_python = unquote_toml_string(val); - } else if (key == "dependencies") { - if (val == "[") { - section = TomlSection::InDepsArray; - } else { - // Inline array: dependencies = ["a", "b"] - out_deps = parse_toml_inline_array(val); - } - } - } else if (section == TomlSection::OrcaPlugin) { - if (key == "name") out_name = unquote_toml_string(val); - else if (key == "description") out_description = unquote_toml_string(val); - else if (key == "author") out_author = unquote_toml_string(val); - else if (key == "version") out_version = unquote_toml_string(val); - } else if (section == TomlSection::OrcaPluginSettings) { - // collect every key as a string; the plugin parses (int/float/...) what it needs. - if (!key.empty()) - out_settings[key] = unquote_toml_string(val); - } - } - - // Check for unclosed arrays. - if (section == TomlSection::InDepsArray) { - error = "PEP 723 metadata: unclosed dependencies array"; - return false; - } - return true; -} - -// Normalize a distribution name to a Python import package name. -// Converts hyphens to underscores and lowercases. -std::string normalize_package_name(const std::string& name) -{ - std::string result; - result.reserve(name.size()); - for (unsigned char ch : name) { - if (ch == '-' || ch == '.') - result += '_'; - else - result += static_cast(std::tolower(ch)); - } - return result; -} - -// Parse METADATA (RFC 822 style) into a flat multimap. -// https://packaging.python.org/en/latest/specifications/core-metadata/ -void parse_metadata_rfc822(const std::string& content, - std::string& out_name, - std::string& out_version, - std::string& out_summary, - std::string& out_author, - std::string& out_requires_python, - std::string& out_import_name, - std::vector& out_requires_dist, - std::string& error) -{ - std::istringstream stream(content); - std::string line; - std::string current_header; - std::string current_value; - - auto flush = [&]() { - if (current_header.empty()) - return; - std::string lower = current_header; - std::transform(lower.begin(), lower.end(), lower.begin(), - [](unsigned char c) { return std::tolower(c); }); - - if (lower == "name") - out_name = current_value; - else if (lower == "version") - out_version = current_value; - else if (lower == "summary") - out_summary = current_value; - else if (lower == "author") - out_author = current_value; - else if (lower == "requires-python") - out_requires_python = current_value; - else if (lower == "import-name") - out_import_name = current_value; - else if (lower == "requires-dist") - out_requires_dist.push_back(current_value); - - current_header.clear(); - current_value.clear(); - }; - - while (std::getline(stream, line)) { - // Blank line after headers marks the start of the body. - if (line.empty() && current_header.empty()) - continue; - if (line.empty()) { - flush(); - // Remaining content is the body (description); stop parsing headers. - break; - } - - // Continuation line. - if (line[0] == ' ' || line[0] == '\t') { - if (!current_value.empty()) - current_value += '\n'; - size_t pos = line.find_first_not_of(" \t"); - current_value += (pos != std::string::npos) ? line.substr(pos) : ""; - continue; - } - - flush(); - - size_t colon = line.find(':'); - if (colon == std::string::npos) - continue; - - current_header = line.substr(0, colon); - size_t val_start = colon + 1; - while (val_start < line.size() && (line[val_start] == ' ' || line[val_start] == '\t')) - ++val_start; - current_value = line.substr(val_start); - } - - flush(); -} - -} // namespace - -bool is_ignored_plugin_directory(const boost::filesystem::path& path) -{ - const std::string name = path.filename().string(); - return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; -} - -bool is_safe_relative_path(const boost::filesystem::path& path) -{ - if (path.empty() || path.is_absolute() || path.has_root_directory() || path.has_root_name()) - return false; - - for (const auto& part : path) { - const std::string token = part.string(); - if (token == "..") - return false; - } - - return true; -} - -bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error) -{ - namespace fs = boost::filesystem; - - boost::system::error_code ec; - fs::create_directories(destination, ec); - if (ec) { - error = "Failed to create plugin staging directory: " + ec.message(); - return false; - } - - ZipReaderGuard reader; - if (!open_zip_reader(&reader.archive, zip_path.string())) { - error = "Failed to open plugin zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - reader.opened = true; - - std::unordered_set extracted_entries; - const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); - mz_zip_archive_file_stat stat; - for (mz_uint i = 0; i < num_entries; ++i) { - if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) { - error = "Failed to read plugin zip entry metadata: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - - std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); - if (entry_name.empty()) - continue; - if (entry_name.find(':') != std::string::npos) { - error = "Plugin zip entry contains an invalid path: " + entry_name; - return false; - } - - const fs::path relative_path(entry_name); - if (!is_safe_archive_entry_path(relative_path)) { - error = "Plugin zip entry escapes the plugin package: " + entry_name; - return false; - } - - const std::string relative_key = relative_path.generic_string(); - if (!extracted_entries.insert(relative_key).second) { - error = "Plugin zip contains duplicate entry: " + relative_key; - return false; - } - - const fs::path output_path = destination / relative_path; - if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { - fs::create_directories(output_path, ec); - if (ec) { - error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); - return false; - } - continue; - } - - fs::create_directories(output_path.parent_path(), ec); - if (ec) { - error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); - return false; - } - - if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { - error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); - return false; - } - - const std::string encoded_output_path = encode_path(output_path.string().c_str()); - mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); -#ifdef WIN32 - if (!extracted) { - const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); - extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); - } -#endif - if (!extracted) { - error = "Failed to extract plugin zip entry " + relative_key + ": " + - MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - } - - return true; -} - -void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry) -{ - PluginInstallState state; - if (!read_install_state(plugin_dir, state)) - return; - - // The cloud identity and the persisted installed version are read back. plugin_key - // is always derived by the catalog scan (filename for local, the cloud uuid for - // cloud), so it is not read from the sidecar. installed_version is the source of - // truth for a cloud plugin's installed version: it records the version fetched from - // the cloud at install time, independent of the (possibly stale) manifest/PEP723 - // header that scan_directory parses into entry.version. - if (!state.installed_version.empty()) - entry.installed_version = state.installed_version; - if (!state.cloud_uuid.empty()) - entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false}; - - // Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a - // capability has no existence — and so no state — until it is materialized, at which point the - // loader seeds the flag onto the capability itself. - entry.enabled = state.enabled; -} - -bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out) -{ - namespace fs = boost::filesystem; - const fs::path sidecar_path = plugin_dir / ".install_state.json"; - - if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path)) - return false; - - boost::nowide::ifstream f(sidecar_path.string()); - if (!f) - return false; - - try { - nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true); - if (state.is_discarded() || !state.is_object()) - return false; - - PluginInstallState parsed; - if (state.contains("installed_from") && state["installed_from"].is_string()) - parsed.installed_from = state["installed_from"].get(); - if (state.contains("installed_version") && state["installed_version"].is_string()) - parsed.installed_version = state["installed_version"].get(); - if (state.contains("plugin_name") && state["plugin_name"].is_string()) - parsed.plugin_name = state["plugin_name"].get(); - if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) - parsed.cloud_uuid = state["cloud_uuid"].get(); - if (state.contains("enabled") && state["enabled"].is_boolean()) - parsed.enabled = state["enabled"].get(); - - // capabilities is a JSON array of single-key objects {: }. - if (state.contains("capabilities") && state["capabilities"].is_array()) { - for (const auto& item : state["capabilities"]) { - if (!item.is_object()) - continue; - for (auto it = item.begin(); it != item.end(); ++it) { - if (it.value().is_boolean()) - parsed.capabilities.emplace_back(it.key(), it.value().get()); - } - } - } - - out = std::move(parsed); - return true; - } catch (...) { - return false; - } -} - -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) -{ - namespace fs = boost::filesystem; - const fs::path sidecar_path = plugin_dir / ".install_state.json"; - - nlohmann::json json; - json["installed_from"] = state.installed_from; - json["installed_version"] = state.installed_version; - json["plugin_name"] = state.plugin_name; - json["enabled"] = state.enabled; - if (!state.cloud_uuid.empty()) - json["cloud_uuid"] = state.cloud_uuid; - - nlohmann::json capabilities = nlohmann::json::array(); - for (const auto& [name, enabled] : state.capabilities) - capabilities.push_back(nlohmann::json{{name, enabled}}); - json["capabilities"] = std::move(capabilities); - - boost::nowide::ofstream f(sidecar_path.string()); - if (!f) - return false; - - f << json.dump(2); - return static_cast(f); -} - -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, - const std::vector>& capabilities) -{ - PluginInstallState state; - state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; - // Prefer the descriptor's recorded installed_version (the version fetched from the cloud - // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header - // never overwrites the source-of-truth version. Fall back to the manifest version for - // first-time/local installs where installed_version is not yet populated. - state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version; - state.plugin_name = entry.name; - state.cloud_uuid = entry.cloud_uuid(); - state.enabled = enabled; - state.capabilities = capabilities; - return write_install_state(plugin_dir, state); -} - -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) -{ - // Install-time writer: the package is not loaded, so its capabilities are not known yet and the - // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real - // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) - // overload. - return write_install_state(plugin_dir, entry, true, {}); -} - -bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(py_path) || !fs::is_regular_file(py_path)) { - error = "Python plugin file does not exist: " + py_path.string(); - return false; - } - - boost::nowide::ifstream f(py_path.string()); - if (!f) { - error = "Failed to open Python plugin file: " + py_path.string(); - return false; - } - - // Scan for PEP 723 inline script metadata block. - // The block is delimited by: - // # /// script - // # - // # /// - std::string pep723_content; - bool in_block = false; - std::string line; - - while (std::getline(f, line)) { - // Strip trailing carriage return (Windows line endings). - if (!line.empty() && line.back() == '\r') - line.pop_back(); - - if (!in_block) { - // Look for opening delimiter. - if (line == "# /// script") - in_block = true; - continue; - } - - if (line == "# ///") { - in_block = false; - continue; - } - - // Extract TOML content from the comment line. - // Lines must start with "# " or "#\t" per PEP 723. - if (line.size() >= 2 && line[0] == '#' && (line[1] == ' ' || line[1] == '\t')) - pep723_content += line.substr(2) + "\n"; - else if (line == "#") - pep723_content += "\n"; - // If line doesn't start with "# ", it's still part of the block content - // but we skip it as it doesn't follow the spec. - } - - if (!pep723_content.empty()) { - std::string pep723_error; - std::string requires_python; - std::string pep_name, pep_desc, pep_author, pep_version; - if (!parse_pep723_toml(pep723_content, - descriptor.dependencies, - requires_python, - pep_name, - pep_desc, - pep_author, - pep_version, - descriptor.settings, - pep723_error)) { - error = "Failed to parse PEP 723 metadata: " + pep723_error; - return false; - } - // requires-python is stored but not validated against the bundled Python here. - (void) requires_python; - - // Populate identity fields from the PEP 723 [tool.orcaslicer.plugin] section. - // Cloud metadata overrides these when available; they serve as the local - // source of truth for side-loaded .py plugins and as fallback values. - if (!pep_name.empty()) descriptor.name = sanitize_plugin_name(pep_name); - if (!pep_desc.empty()) descriptor.description = pep_desc; - if (!pep_author.empty()) descriptor.author = pep_author; - if (!pep_version.empty()) descriptor.version = pep_version; - } - - // Validate that required identity fields are present (either from PEP 723 or - // from cloud metadata already set on the manifest by the caller). - // Validation is deferred to the install/discovery layer so cloud metadata - // can fill in gaps. - return true; -} - -bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(whl_path) || !fs::is_regular_file(whl_path)) { - error = "Wheel plugin file does not exist: " + whl_path.string(); - return false; - } - - ZipReaderGuard reader; - if (!open_zip_reader(&reader.archive, whl_path.string())) { - error = "Failed to open wheel as zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - reader.opened = true; - - // Find the single .dist-info directory. - // Scan ALL entries, not just directory entries — some zip writers omit - // explicit directory entries. normalize_zip_entry_name strips trailing - // slashes, so we match ".dist-info" as the last path component of a - // directory entry, and ".dist-info/" embedded in a file path. - const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); - std::string dist_info_dir; - mz_zip_archive_file_stat stat; - - for (mz_uint i = 0; i < num_entries; ++i) { - if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) - continue; - - std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); - if (entry_name.empty()) - continue; - - // Find .dist-info as a path component. - size_t pos = entry_name.find(".dist-info"); - if (pos == std::string::npos) - continue; - - std::string candidate; - if (pos + 10 == entry_name.size()) { - // Directory entry itself (trailing / stripped by normalize). - candidate = entry_name + "/"; - } else if (pos + 10 < entry_name.size() && entry_name[pos + 10] == '/') { - // File inside .dist-info/: name.dist-info/METADATA - candidate = entry_name.substr(0, pos + 11); // include trailing / - } else { - continue; // .dist-info mid-name, not a path component. - } - - if (!dist_info_dir.empty() && candidate != dist_info_dir) { - error = "Wheel contains multiple .dist-info directories: " + dist_info_dir + " and " + candidate; - return false; - } - dist_info_dir = candidate; - } - - if (dist_info_dir.empty()) { - error = "Wheel does not contain a .dist-info directory"; - return false; - } - - // Read METADATA. - const std::string metadata_path = dist_info_dir + "METADATA"; - std::string meta_content; - if (!read_zip_text_file(reader.archive, metadata_path.c_str(), meta_content, error)) - return false; - - std::string meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name; - std::vector requires_dist; - std::string meta_error; - parse_metadata_rfc822(meta_content, meta_name, meta_version, meta_summary, meta_author, - meta_requires_python, meta_import_name, requires_dist, meta_error); - - if (meta_name.empty()) { - error = "Wheel METADATA missing required Name field"; - return false; - } - if (meta_version.empty()) { - error = "Wheel METADATA missing required Version field"; - return false; - } - - // Read WHEEL (verify existence and Wheel-Version). - const std::string wheel_path = dist_info_dir + "WHEEL"; - std::string wheel_content; - if (!read_zip_text_file(reader.archive, wheel_path.c_str(), wheel_content, error)) - return false; - // Verify there's at least a Wheel-Version header line. - if (wheel_content.find("Wheel-Version:") == std::string::npos) { - error = "Wheel WHEEL file missing Wheel-Version header"; - return false; - } - - // Parse and validate wheel platform tags. - { - std::vector wheel_tags; - std::istringstream wstream(wheel_content); - std::string wline; - while (std::getline(wstream, wline)) { - while (!wline.empty() && (wline.back() == '\r' || wline.back() == '\n')) - wline.pop_back(); - if (wline.rfind("Tag:", 0) == 0) { - std::string tag = wline.substr(4); - size_t s = 0, e = tag.size(); - while (s < e && (tag[s] == ' ' || tag[s] == '\t')) ++s; - while (e > s && (tag[e - 1] == ' ' || tag[e - 1] == '\t')) --e; - wheel_tags.push_back(tag.substr(s, e - s)); - } - } - - if (!wheel_tags.empty()) { - bool compatible = false; - const std::string abi_tag = PythonInterpreter::python_abi_tag(); - for (const auto& tag : wheel_tags) { - // Pure Python wheel: py3-none-any or cp312-none-any - if (tag.find("-none-any") != std::string::npos) { - compatible = true; - break; - } - // Platform-specific: check ABI tag matches. - if (tag.find(abi_tag) == 0) { - // Accept if the platform tag matches the current OS. -#ifdef _WIN32 - if (tag.find("-win") != std::string::npos) - compatible = true; -#elif __APPLE__ - if (tag.find("-macosx") != std::string::npos) - compatible = true; -#else - if (tag.find("-linux") != std::string::npos || tag.find("-manylinux") != std::string::npos) - compatible = true; -#endif - } - } - if (!compatible) { - error = "Wheel is incompatible with this platform. Tags: "; - for (size_t i = 0; i < wheel_tags.size(); ++i) { - if (i > 0) error += ", "; - error += wheel_tags[i]; - } - error += "; expected ABI: " + abi_tag; - return false; - } - } - } - - // Read RECORD (verify existence). - const std::string record_path = dist_info_dir + "RECORD"; - std::string record_content; - if (!read_zip_text_file(reader.archive, record_path.c_str(), record_content, error)) - return false; - if (record_content.empty()) { - error = "Wheel RECORD file is empty"; - return false; - } - - // Parse top_level.txt if present. - std::string top_level; - const std::string top_level_path = dist_info_dir + "top_level.txt"; - std::string top_level_content; - if (read_zip_text_file(reader.archive, top_level_path.c_str(), top_level_content, error)) { - // top_level.txt contains one package name per line. - std::istringstream tl_stream(top_level_content); - std::string tl_line; - std::vector top_levels; - while (std::getline(tl_stream, tl_line)) { - while (!tl_line.empty() && (tl_line.back() == '\r' || tl_line.back() == '\n')) - tl_line.pop_back(); - if (!tl_line.empty()) - top_levels.push_back(tl_line); - } - if (top_levels.size() == 1) - top_level = top_levels[0]; - else if (top_levels.size() > 1) { - // Ambiguous: multiple top-level packages. Fall through to Name-based fallback. - } - // Zero entries: leave top_level empty. - } - // If top_level.txt is not found, that's OK — it's optional per the wheel spec. - - // Determine the entry package in priority order. - // 1. Cloud/catalog metadata — handled by caller, not here. - // 2. Core Metadata Import-Name. - // 3. top_level.txt if unambiguous. - // 4. Normalized Name as fallback. - if (!meta_import_name.empty()) { - descriptor.entry_package = meta_import_name; - } else if (!top_level.empty()) { - descriptor.entry_package = top_level; - } else { - descriptor.entry_package = normalize_package_name(meta_name); - } - - descriptor.dependencies = std::move(requires_dist); - - // Populate local identity fallbacks from wheel metadata. - // Cloud metadata will override these when available. - descriptor.name = sanitize_plugin_name(meta_name); - descriptor.version = meta_version; - descriptor.description = meta_summary; - descriptor.author = meta_author; - - return true; -} - -boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { - error = "Plugin directory does not exist: " + plugin_dir.string(); - return {}; - } - - fs::path py_entry; - fs::path whl_entry; - - for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { - if (is_ignored_plugin_directory(it->path())) - continue; - if (!fs::is_regular_file(it->status())) - continue; - - const fs::path ext = it->path().extension(); - if (ext == ".py") { - if (!py_entry.empty()) { - error = "Plugin directory contains multiple .py files: " + py_entry.filename().string() + - " and " + it->path().filename().string(); - return {}; - } - py_entry = it->path(); - } else if (ext == ".whl") { - if (!whl_entry.empty()) { - error = "Plugin directory contains multiple .whl files: " + whl_entry.filename().string() + - " and " + it->path().filename().string(); - return {}; - } - whl_entry = it->path(); - } - } - - if (!py_entry.empty() && !whl_entry.empty()) { - error = "Plugin directory contains both .py and .whl entry files"; - return {}; - } - - if (!py_entry.empty()) - return py_entry; - - if (!whl_entry.empty()) - return whl_entry; - - error = "Plugin directory does not contain a .py or .whl entry file"; - return {}; -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PythonFileUtils.hpp b/src/slic3r/plugin/PythonFileUtils.hpp deleted file mode 100644 index e25e5b49fa..0000000000 --- a/src/slic3r/plugin/PythonFileUtils.hpp +++ /dev/null @@ -1,61 +0,0 @@ -#ifndef slic3r_PythonFileUtils_hpp_ -#define slic3r_PythonFileUtils_hpp_ - -#include - -#include -#include -#include - -namespace Slic3r { - -struct PluginDescriptor; - -// Persisted per-plugin install/auto-load state, stored in the .install_state.json sidecar. -// This replaces app_config as the source of truth for auto-load and capability enable state. -struct PluginInstallState { - std::string installed_from; // "local" | "cloud" - std::string installed_version; - std::string plugin_name; - std::string cloud_uuid; // empty for local - bool enabled = true; - std::vector> capabilities; // name -> enabled, ordered -}; - -bool is_ignored_plugin_directory(const boost::filesystem::path& path); -bool is_safe_relative_path(const boost::filesystem::path& path); -bool is_valid_plugin_id(const std::string& id); -bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error); - -// Read PEP 723 inline script metadata from a .py plugin file. -// Populates metadata.dependencies and local identity fallbacks. -// Returns true on success (including when no PEP 723 block is found — deps will be empty). -bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error); - -// Read wheel metadata from a .whl plugin file (zip archive). -// Reads METADATA, WHEEL, RECORD, and top_level.txt from the .dist-info directory. -// Populates metadata.entry_package, metadata.dependencies, and local identity fallbacks. -// Returns true on success. -bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error); - -// Find the single plugin entry file (.py or .whl) in a directory. -// Ignores __whl_extracted__ and hidden files/dirs. -// Returns the path to the entry file, or an empty path with error set if zero or multiple candidates. -boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error); - -// Canonical writer: emits the .install_state.json schema for the given state. -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); -// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, - const std::vector>& capabilities); -// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); - -// Reads only the cloud identity (uuid) back into the descriptor; plugin_key is always derived. -void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); -// Full read of the sidecar; returns false if there is no/invalid sidecar. -bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out); - -} // namespace Slic3r - -#endif // slic3r_PythonFileUtils_hpp_ diff --git a/src/slic3r/plugin/PythonInterpreter.cpp b/src/slic3r/plugin/PythonInterpreter.cpp index 2ff275ccb3..e46ee58f80 100644 --- a/src/slic3r/plugin/PythonInterpreter.cpp +++ b/src/slic3r/plugin/PythonInterpreter.cpp @@ -4,7 +4,7 @@ #include "PluginAuditManager.hpp" #include #include -#include "PythonFileUtils.hpp" +#include "PluginFsUtils.hpp" #include diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp index 350f8be506..8605532c12 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -2,7 +2,7 @@ #include "SlicingPipelinePluginCapability.hpp" #include "slic3r/plugin/PyPluginTrampoline.hpp" #include "slic3r/plugin/PluginAuditManager.hpp" -#include +#include namespace Slic3r { class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline { @@ -20,7 +20,7 @@ public: // granted to the geometry hooks. if (!ctx.gcode_path.empty()) ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( - std::filesystem::path(ctx.gcode_path).parent_path()); + boost::filesystem::path(ctx.gcode_path).parent_path()); }, PYBIND11_OVERRIDE_PURE, ExecutionResult, SlicingPipelinePluginCapability, execute, ctx); diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 430564a4fc..1b47454691 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include diff --git a/tests/slic3rutils/test_plugin_lifecycle.cpp b/tests/slic3rutils/test_plugin_lifecycle.cpp index 5f8a45452b..278740c24b 100644 --- a/tests/slic3rutils/test_plugin_lifecycle.cpp +++ b/tests/slic3rutils/test_plugin_lifecycle.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include #include