mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-16 15:32:09 +00:00
fix: merge PythonFileUtils into PluginFsUtils
This commit is contained in:
@@ -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 || "")
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<PluginDescriptor> current_cloud_catalog_snapshot()
|
||||
std::vector<PluginDescriptor> current_cloud_metadata_snapshot()
|
||||
{
|
||||
std::vector<PluginDescriptor> 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<std::string> not_found, unauthorized;
|
||||
const std::vector<PluginDescriptor> current_cloud_catalog = fetch_cloud ? std::vector<PluginDescriptor>{} :
|
||||
current_cloud_catalog_snapshot();
|
||||
const std::vector<PluginDescriptor> current_cloud_metadata = fetch_cloud ? std::vector<PluginDescriptor>{} :
|
||||
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<PluginCapabilityInterface> 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<PluginOperationState>();
|
||||
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<PluginDescriptor>{as_cloud_only_descriptor(plugin)});
|
||||
manager.update_cloud_metadata(std::vector<PluginDescriptor>{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");
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ private:
|
||||
bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const;
|
||||
std::shared_ptr<PluginCapabilityInterface> 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().
|
||||
|
||||
@@ -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<std::filesystem::path> PluginAuditManager::m_scoped_allowed_roots;
|
||||
thread_local std::vector<boost::filesystem::path> 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);
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
#define slic3r_PluginAuditManager_hpp_
|
||||
|
||||
#include <Python.h>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
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<std::filesystem::path> m_scoped_allowed_roots;
|
||||
static thread_local std::vector<boost::filesystem::path> 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<std::filesystem::path> m_global_allowed_roots;
|
||||
std::vector<boost::filesystem::path> 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<std::filesystem::path> m_previous_scoped_roots;
|
||||
std::vector<boost::filesystem::path> m_previous_scoped_roots;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
#include "PluginFsUtils.hpp"
|
||||
|
||||
#include "PythonFileUtils.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "libslic3r/miniz_extension.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <sstream>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "PluginAuditManager.hpp"
|
||||
#ifdef WIN32
|
||||
#include <boost/locale/encoding_utf.hpp>
|
||||
#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<PluginDescriptor> discover_plugin_packages(const std::vector<std::st
|
||||
return discovered;
|
||||
}
|
||||
|
||||
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<std::uint16_t>(static_cast<unsigned char>(p[2])) |
|
||||
static_cast<std::uint16_t>(static_cast<unsigned char>(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<const char*>(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<std::string> parse_toml_inline_array(const std::string& val)
|
||||
{
|
||||
std::vector<std::string> 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<std::string>& 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<std::string, std::string>& 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<char>(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<std::string>& 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<std::string> 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<wchar_t>(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<std::string>();
|
||||
if (state.contains("installed_version") && state["installed_version"].is_string())
|
||||
parsed.installed_version = state["installed_version"].get<std::string>();
|
||||
if (state.contains("plugin_name") && state["plugin_name"].is_string())
|
||||
parsed.plugin_name = state["plugin_name"].get<std::string>();
|
||||
if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string())
|
||||
parsed.cloud_uuid = state["cloud_uuid"].get<std::string>();
|
||||
if (state.contains("enabled") && state["enabled"].is_boolean())
|
||||
parsed.enabled = state["enabled"].get<bool>();
|
||||
|
||||
// capabilities is a JSON array of single-key objects {<cap_name>: <bool>}.
|
||||
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<bool>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<bool>(f);
|
||||
}
|
||||
|
||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
|
||||
const std::vector<std::pair<std::string, bool>>& 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
|
||||
// # <toml content>
|
||||
// # ///
|
||||
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<std::string> 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<std::string> 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<std::string> 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
|
||||
|
||||
@@ -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<std::pair<std::string, bool>> 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<std::string> 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<PluginDescriptor> discover_plugin_packages(const std::vector<std::string>& 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<std::pair<std::string, bool>>& 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
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
#include "PluginManager.hpp"
|
||||
|
||||
#include "PluginFsUtils.hpp"
|
||||
#include "PythonFileUtils.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PythonPluginBridge.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
@@ -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<PluginDescriptor>& cloud_list)
|
||||
void PluginManager::update_cloud_metadata(const std::vector<PluginDescriptor>& cloud_list)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
|
||||
@@ -1353,7 +1352,7 @@ void PluginManager::update_cloud_catalog(const std::vector<PluginDescriptor>& 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<std::string>* out_not_found, std::vector<std::string>* out_unauthorized)
|
||||
@@ -1394,7 +1393,7 @@ void PluginManager::fetch_plugins_from_cloud(std::vector<std::string>* out_not_f
|
||||
}
|
||||
}
|
||||
|
||||
update_cloud_catalog(cloud_list);
|
||||
update_cloud_metadata(cloud_list);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -194,8 +194,8 @@ public:
|
||||
bool clear_plugin_error(const std::string& plugin_key);
|
||||
|
||||
void fetch_plugins_from_cloud(std::vector<std::string>* out_not_found = nullptr, std::vector<std::string>* out_unauthorized = nullptr);
|
||||
void update_cloud_catalog(const std::vector<PluginDescriptor>& cloud_list);
|
||||
void clear_cloud_plugin_catalog();
|
||||
void update_cloud_metadata(const std::vector<PluginDescriptor>& 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);
|
||||
|
||||
@@ -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 <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <sstream>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#ifdef WIN32
|
||||
#include <boost/locale/encoding_utf.hpp>
|
||||
#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<std::uint16_t>(static_cast<unsigned char>(p[2])) |
|
||||
static_cast<std::uint16_t>(static_cast<unsigned char>(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<const char*>(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<std::string> parse_toml_inline_array(const std::string& val)
|
||||
{
|
||||
std::vector<std::string> 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<std::string>& 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<std::string, std::string>& 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<char>(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<std::string>& 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<std::string> 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<wchar_t>(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<std::string>();
|
||||
if (state.contains("installed_version") && state["installed_version"].is_string())
|
||||
parsed.installed_version = state["installed_version"].get<std::string>();
|
||||
if (state.contains("plugin_name") && state["plugin_name"].is_string())
|
||||
parsed.plugin_name = state["plugin_name"].get<std::string>();
|
||||
if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string())
|
||||
parsed.cloud_uuid = state["cloud_uuid"].get<std::string>();
|
||||
if (state.contains("enabled") && state["enabled"].is_boolean())
|
||||
parsed.enabled = state["enabled"].get<bool>();
|
||||
|
||||
// capabilities is a JSON array of single-key objects {<cap_name>: <bool>}.
|
||||
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<bool>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<bool>(f);
|
||||
}
|
||||
|
||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
|
||||
const std::vector<std::pair<std::string, bool>>& 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
|
||||
// # <toml content>
|
||||
// # ///
|
||||
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<std::string> 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<std::string> 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<std::string> 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
|
||||
@@ -1,61 +0,0 @@
|
||||
#ifndef slic3r_PythonFileUtils_hpp_
|
||||
#define slic3r_PythonFileUtils_hpp_
|
||||
|
||||
#include <boost/filesystem/path.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<std::pair<std::string, bool>> 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<std::pair<std::string, bool>>& 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_
|
||||
@@ -4,7 +4,7 @@
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <pytypedefs.h>
|
||||
#include "PythonFileUtils.hpp"
|
||||
#include "PluginFsUtils.hpp"
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include "SlicingPipelinePluginCapability.hpp"
|
||||
#include "slic3r/plugin/PyPluginTrampoline.hpp"
|
||||
#include "slic3r/plugin/PluginAuditManager.hpp"
|
||||
#include <filesystem>
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
namespace Slic3r {
|
||||
class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline<SlicingPipelinePluginCapability> {
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
Reference in New Issue
Block a user