#include "PythonFileUtils.hpp" #include "PluginFsUtils.hpp" #include "PythonInterpreter.hpp" #include "libslic3r/Utils.hpp" #include "PluginDescriptor.hpp" #include "libslic3r/miniz_extension.hpp" #include #include #include #include #include #include #include #include #include #include #ifdef WIN32 #include #endif namespace Slic3r { namespace { bool is_safe_archive_entry_path(const boost::filesystem::path& path) { if (!is_safe_relative_path(path)) return false; for (const auto& part : path) { const std::string token = part.string(); if (token.empty() || token == "." || token == "..") return false; } return true; } std::string decode_zip_entry_extra_path(const std::string& extra, const std::string& fallback) { const char* p = extra.data(); const char* e = p + extra.length(); while (p + 4 <= e) { const auto len = static_cast(static_cast(p[2])) | static_cast(static_cast(p[3]) << 8); if (p[0] == '\x75' && p[1] == '\x70' && len >= 5 && p + 4 + len <= e && p[4] == '\x01') return std::string(p + 9, p + 4 + len); p += 4 + len; } return decode_path(fallback.c_str()); } std::string zip_entry_name(mz_zip_archive& archive, const mz_zip_archive_file_stat& stat) { if (stat.m_is_utf8) return stat.m_filename; std::string extra(1024, 0); const size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); return decode_zip_entry_extra_path(extra.substr(0, n), stat.m_filename); } std::string normalize_zip_entry_name(std::string entry_name) { std::replace(entry_name.begin(), entry_name.end(), '\\', '/'); while (!entry_name.empty() && entry_name.back() == '/') entry_name.pop_back(); return entry_name; } struct ZipReaderGuard { mz_zip_archive archive; bool opened = false; ZipReaderGuard() { mz_zip_zero_struct(&archive); } ~ZipReaderGuard() { if (opened) close_zip_reader(&archive); } }; } // namespace bool is_valid_plugin_id(const std::string& id) { if (id.empty()) return false; if (id == "." || id == ".." || id[0] == '.' || id.rfind("__", 0) == 0) return false; for (unsigned char ch : id) { if (std::isalnum(ch) || ch == '_' || ch == '-' || ch == '.') continue; return false; } return true; } namespace { // RAII helper to free heap-allocated memory from miniz. struct MzHeapFree { void* ptr = nullptr; ~MzHeapFree() { if (ptr) std::free(ptr); } }; // Read a text file from within a zip archive into a string. // Returns true on success, false if the file is not found or cannot be read. bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::string& out, std::string& error) { size_t size = 0; void* data = mz_zip_reader_extract_file_to_heap(&archive, filename, &size, 0); if (!data) { error = std::string("Wheel does not contain ") + filename; return false; } MzHeapFree guard{data}; out.assign(static_cast(data), size); return true; } // TOML section parsing states. enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; // Strip a quoted string value: "foo" → foo, 'foo' → foo. // Returns the unquoted value or the input unchanged if not quoted. std::string unquote_toml_string(const std::string& val) { if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\''))) return val.substr(1, val.size() - 2); return val; } // Split a TOML inline array: ["a", "b"] → {"a", "b"}. // Handles trailing commas and single-line format. std::vector parse_toml_inline_array(const std::string& val) { std::vector result; std::string inner = val; // Strip outer brackets. if (!inner.empty() && inner.front() == '[') inner.erase(0, 1); if (!inner.empty() && inner.back() == ']') inner.pop_back(); // Simple split by comma, strip quotes and whitespace. std::istringstream ss(inner); std::string item; while (std::getline(ss, item, ',')) { // Trim whitespace. size_t s = 0, e = item.size(); while (s < e && (item[s] == ' ' || item[s] == '\t')) ++s; while (e > s && (item[e - 1] == ' ' || item[e - 1] == '\t')) --e; item = item.substr(s, e - s); if (!item.empty()) result.push_back(unquote_toml_string(item)); } return result; } // Parse PEP 723 TOML subset for dependencies, requires-python, and // [tool.orcaslicer.plugin] identity fields. // // requires-python = ">=3.12" // dependencies = ["pkg>=1.0", ] // // [tool.orcaslicer.plugin] // id = "my-plugin" // name = "My Plugin" // description = "Does things." // author = "Author" // version = "1.0.0" // // Returns false only on parse errors; missing block is not an error. bool parse_pep723_toml(const std::string& toml_content, std::vector& out_deps, std::string& out_requires_python, std::string& out_name, std::string& out_description, std::string& out_author, std::string& out_version, std::map& out_settings, std::string& error) { out_deps.clear(); out_requires_python.clear(); out_name.clear(); out_description.clear(); out_author.clear(); out_version.clear(); out_settings.clear(); TomlSection section = TomlSection::Root; std::istringstream stream(toml_content); std::string line; while (std::getline(stream, line)) { // Trim leading/trailing whitespace. size_t start = 0; while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) ++start; size_t end = line.size(); while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t')) --end; std::string trimmed = line.substr(start, end - start); if (trimmed.empty() || trimmed[0] == '#') continue; // TOML section header. if (trimmed[0] == '[') { if (trimmed == "[tool.orcaslicer.plugin]") { section = TomlSection::OrcaPlugin; } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { section = TomlSection::OrcaPluginSettings; // per-plugin params table } else { section = TomlSection::Root; // Unknown section — skip. } continue; } if (section == TomlSection::InDepsArray) { if (trimmed == "]") { section = TomlSection::Root; continue; } std::string val = trimmed; if (!val.empty() && val.back() == ',') val.pop_back(); val = unquote_toml_string(val); if (!val.empty()) out_deps.push_back(val); continue; } // Look for key = value. size_t eq = trimmed.find('='); if (eq == std::string::npos) continue; std::string key = trimmed.substr(0, eq); while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back(); std::string val = trimmed.substr(eq + 1); while (!val.empty() && (val.front() == ' ' || val.front() == '\t')) val.erase(0, 1); // Trim trailing. while (!val.empty() && (val.back() == ' ' || val.back() == '\t')) val.pop_back(); if (section == TomlSection::Root) { if (key == "requires-python") { out_requires_python = unquote_toml_string(val); } else if (key == "dependencies") { if (val == "[") { section = TomlSection::InDepsArray; } else { // Inline array: dependencies = ["a", "b"] out_deps = parse_toml_inline_array(val); } } } else if (section == TomlSection::OrcaPlugin) { if (key == "name") out_name = unquote_toml_string(val); else if (key == "description") out_description = unquote_toml_string(val); else if (key == "author") out_author = unquote_toml_string(val); else if (key == "version") out_version = unquote_toml_string(val); } else if (section == TomlSection::OrcaPluginSettings) { // collect every key as a string; the plugin parses (int/float/...) what it needs. if (!key.empty()) out_settings[key] = unquote_toml_string(val); } } // Check for unclosed arrays. if (section == TomlSection::InDepsArray) { error = "PEP 723 metadata: unclosed dependencies array"; return false; } return true; } // Normalize a distribution name to a Python import package name. // Converts hyphens to underscores and lowercases. std::string normalize_package_name(const std::string& name) { std::string result; result.reserve(name.size()); for (unsigned char ch : name) { if (ch == '-' || ch == '.') result += '_'; else result += static_cast(std::tolower(ch)); } return result; } // Parse METADATA (RFC 822 style) into a flat multimap. // https://packaging.python.org/en/latest/specifications/core-metadata/ void parse_metadata_rfc822(const std::string& content, std::string& out_name, std::string& out_version, std::string& out_summary, std::string& out_author, std::string& out_requires_python, std::string& out_import_name, std::vector& out_requires_dist, std::string& error) { std::istringstream stream(content); std::string line; std::string current_header; std::string current_value; auto flush = [&]() { if (current_header.empty()) return; std::string lower = current_header; std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); }); if (lower == "name") out_name = current_value; else if (lower == "version") out_version = current_value; else if (lower == "summary") out_summary = current_value; else if (lower == "author") out_author = current_value; else if (lower == "requires-python") out_requires_python = current_value; else if (lower == "import-name") out_import_name = current_value; else if (lower == "requires-dist") out_requires_dist.push_back(current_value); current_header.clear(); current_value.clear(); }; while (std::getline(stream, line)) { // Blank line after headers marks the start of the body. if (line.empty() && current_header.empty()) continue; if (line.empty()) { flush(); // Remaining content is the body (description); stop parsing headers. break; } // Continuation line. if (line[0] == ' ' || line[0] == '\t') { if (!current_value.empty()) current_value += '\n'; size_t pos = line.find_first_not_of(" \t"); current_value += (pos != std::string::npos) ? line.substr(pos) : ""; continue; } flush(); size_t colon = line.find(':'); if (colon == std::string::npos) continue; current_header = line.substr(0, colon); size_t val_start = colon + 1; while (val_start < line.size() && (line[val_start] == ' ' || line[val_start] == '\t')) ++val_start; current_value = line.substr(val_start); } flush(); } } // namespace bool is_ignored_plugin_directory(const boost::filesystem::path& path) { const std::string name = path.filename().string(); return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; } bool is_safe_relative_path(const boost::filesystem::path& path) { if (path.empty() || path.is_absolute() || path.has_root_directory() || path.has_root_name()) return false; for (const auto& part : path) { const std::string token = part.string(); if (token == "..") return false; } return true; } bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error) { namespace fs = boost::filesystem; boost::system::error_code ec; fs::create_directories(destination, ec); if (ec) { error = "Failed to create plugin staging directory: " + ec.message(); return false; } ZipReaderGuard reader; if (!open_zip_reader(&reader.archive, zip_path.string())) { error = "Failed to open plugin zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); return false; } reader.opened = true; std::unordered_set extracted_entries; const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); mz_zip_archive_file_stat stat; for (mz_uint i = 0; i < num_entries; ++i) { if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) { error = "Failed to read plugin zip entry metadata: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); return false; } std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); if (entry_name.empty()) continue; if (entry_name.find(':') != std::string::npos) { error = "Plugin zip entry contains an invalid path: " + entry_name; return false; } const fs::path relative_path(entry_name); if (!is_safe_archive_entry_path(relative_path)) { error = "Plugin zip entry escapes the plugin package: " + entry_name; return false; } const std::string relative_key = relative_path.generic_string(); if (!extracted_entries.insert(relative_key).second) { error = "Plugin zip contains duplicate entry: " + relative_key; return false; } const fs::path output_path = destination / relative_path; if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { fs::create_directories(output_path, ec); if (ec) { error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); return false; } continue; } fs::create_directories(output_path.parent_path(), ec); if (ec) { error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); return false; } if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); return false; } const std::string encoded_output_path = encode_path(output_path.string().c_str()); mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); #ifdef WIN32 if (!extracted) { const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); } #endif if (!extracted) { error = "Failed to extract plugin zip entry " + relative_key + ": " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); return false; } } return true; } void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry) { PluginInstallState state; if (!read_install_state(plugin_dir, state)) return; // The cloud identity and the persisted installed version are read back. plugin_key // is always derived by the catalog scan (filename for local, the cloud uuid for // cloud), so it is not read from the sidecar. installed_version is the source of // truth for a cloud plugin's installed version: it records the version fetched from // the cloud at install time, independent of the (possibly stale) manifest/PEP723 // header that scan_directory parses into entry.version. if (!state.installed_version.empty()) entry.installed_version = state.installed_version; if (!state.cloud_uuid.empty()) entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false}; // Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a // capability has no existence — and so no state — until it is materialized, at which point the // loader seeds the flag onto the capability itself. entry.enabled = state.enabled; } bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out) { namespace fs = boost::filesystem; const fs::path sidecar_path = plugin_dir / ".install_state.json"; if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path)) return false; boost::nowide::ifstream f(sidecar_path.string()); if (!f) return false; try { nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true); if (state.is_discarded() || !state.is_object()) return false; PluginInstallState parsed; if (state.contains("installed_from") && state["installed_from"].is_string()) parsed.installed_from = state["installed_from"].get(); if (state.contains("installed_version") && state["installed_version"].is_string()) parsed.installed_version = state["installed_version"].get(); if (state.contains("plugin_name") && state["plugin_name"].is_string()) parsed.plugin_name = state["plugin_name"].get(); if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) parsed.cloud_uuid = state["cloud_uuid"].get(); if (state.contains("enabled") && state["enabled"].is_boolean()) parsed.enabled = state["enabled"].get(); // capabilities is a JSON array of single-key objects {: }. if (state.contains("capabilities") && state["capabilities"].is_array()) { for (const auto& item : state["capabilities"]) { if (!item.is_object()) continue; for (auto it = item.begin(); it != item.end(); ++it) { if (it.value().is_boolean()) parsed.capabilities.emplace_back(it.key(), it.value().get()); } } } out = std::move(parsed); return true; } catch (...) { return false; } } bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) { namespace fs = boost::filesystem; const fs::path sidecar_path = plugin_dir / ".install_state.json"; nlohmann::json json; json["installed_from"] = state.installed_from; json["installed_version"] = state.installed_version; json["plugin_name"] = state.plugin_name; json["enabled"] = state.enabled; if (!state.cloud_uuid.empty()) json["cloud_uuid"] = state.cloud_uuid; nlohmann::json capabilities = nlohmann::json::array(); for (const auto& [name, enabled] : state.capabilities) capabilities.push_back(nlohmann::json{{name, enabled}}); json["capabilities"] = std::move(capabilities); boost::nowide::ofstream f(sidecar_path.string()); if (!f) return false; f << json.dump(2); return static_cast(f); } bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, const std::vector>& capabilities) { PluginInstallState state; state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; // Prefer the descriptor's recorded installed_version (the version fetched from the cloud // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header // never overwrites the source-of-truth version. Fall back to the manifest version for // first-time/local installs where installed_version is not yet populated. state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version; state.plugin_name = entry.name; state.cloud_uuid = entry.cloud_uuid(); state.enabled = enabled; state.capabilities = capabilities; return write_install_state(plugin_dir, state); } bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) { // Install-time writer: the package is not loaded, so its capabilities are not known yet and the // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) // overload. return write_install_state(plugin_dir, entry, true, {}); } bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) { namespace fs = boost::filesystem; if (!fs::exists(py_path) || !fs::is_regular_file(py_path)) { error = "Python plugin file does not exist: " + py_path.string(); return false; } boost::nowide::ifstream f(py_path.string()); if (!f) { error = "Failed to open Python plugin file: " + py_path.string(); return false; } // Scan for PEP 723 inline script metadata block. // The block is delimited by: // # /// script // # // # /// std::string pep723_content; bool in_block = false; std::string line; while (std::getline(f, line)) { // Strip trailing carriage return (Windows line endings). if (!line.empty() && line.back() == '\r') line.pop_back(); if (!in_block) { // Look for opening delimiter. if (line == "# /// script") in_block = true; continue; } if (line == "# ///") { in_block = false; continue; } // Extract TOML content from the comment line. // Lines must start with "# " or "#\t" per PEP 723. if (line.size() >= 2 && line[0] == '#' && (line[1] == ' ' || line[1] == '\t')) pep723_content += line.substr(2) + "\n"; else if (line == "#") pep723_content += "\n"; // If line doesn't start with "# ", it's still part of the block content // but we skip it as it doesn't follow the spec. } if (!pep723_content.empty()) { std::string pep723_error; std::string requires_python; std::string pep_name, pep_desc, pep_author, pep_version; if (!parse_pep723_toml(pep723_content, descriptor.dependencies, requires_python, pep_name, pep_desc, pep_author, pep_version, descriptor.settings, pep723_error)) { error = "Failed to parse PEP 723 metadata: " + pep723_error; return false; } // requires-python is stored but not validated against the bundled Python here. (void) requires_python; // Populate identity fields from the PEP 723 [tool.orcaslicer.plugin] section. // Cloud metadata overrides these when available; they serve as the local // source of truth for side-loaded .py plugins and as fallback values. if (!pep_name.empty()) descriptor.name = sanitize_plugin_name(pep_name); if (!pep_desc.empty()) descriptor.description = pep_desc; if (!pep_author.empty()) descriptor.author = pep_author; if (!pep_version.empty()) descriptor.version = pep_version; } // Validate that required identity fields are present (either from PEP 723 or // from cloud metadata already set on the manifest by the caller). // Validation is deferred to the install/discovery layer so cloud metadata // can fill in gaps. return true; } bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error) { namespace fs = boost::filesystem; if (!fs::exists(whl_path) || !fs::is_regular_file(whl_path)) { error = "Wheel plugin file does not exist: " + whl_path.string(); return false; } ZipReaderGuard reader; if (!open_zip_reader(&reader.archive, whl_path.string())) { error = "Failed to open wheel as zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); return false; } reader.opened = true; // Find the single .dist-info directory. // Scan ALL entries, not just directory entries — some zip writers omit // explicit directory entries. normalize_zip_entry_name strips trailing // slashes, so we match ".dist-info" as the last path component of a // directory entry, and ".dist-info/" embedded in a file path. const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); std::string dist_info_dir; mz_zip_archive_file_stat stat; for (mz_uint i = 0; i < num_entries; ++i) { if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) continue; std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); if (entry_name.empty()) continue; // Find .dist-info as a path component. size_t pos = entry_name.find(".dist-info"); if (pos == std::string::npos) continue; std::string candidate; if (pos + 10 == entry_name.size()) { // Directory entry itself (trailing / stripped by normalize). candidate = entry_name + "/"; } else if (pos + 10 < entry_name.size() && entry_name[pos + 10] == '/') { // File inside .dist-info/: name.dist-info/METADATA candidate = entry_name.substr(0, pos + 11); // include trailing / } else { continue; // .dist-info mid-name, not a path component. } if (!dist_info_dir.empty() && candidate != dist_info_dir) { error = "Wheel contains multiple .dist-info directories: " + dist_info_dir + " and " + candidate; return false; } dist_info_dir = candidate; } if (dist_info_dir.empty()) { error = "Wheel does not contain a .dist-info directory"; return false; } // Read METADATA. const std::string metadata_path = dist_info_dir + "METADATA"; std::string meta_content; if (!read_zip_text_file(reader.archive, metadata_path.c_str(), meta_content, error)) return false; std::string meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name; std::vector requires_dist; std::string meta_error; parse_metadata_rfc822(meta_content, meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name, requires_dist, meta_error); if (meta_name.empty()) { error = "Wheel METADATA missing required Name field"; return false; } if (meta_version.empty()) { error = "Wheel METADATA missing required Version field"; return false; } // Read WHEEL (verify existence and Wheel-Version). const std::string wheel_path = dist_info_dir + "WHEEL"; std::string wheel_content; if (!read_zip_text_file(reader.archive, wheel_path.c_str(), wheel_content, error)) return false; // Verify there's at least a Wheel-Version header line. if (wheel_content.find("Wheel-Version:") == std::string::npos) { error = "Wheel WHEEL file missing Wheel-Version header"; return false; } // Parse and validate wheel platform tags. { std::vector wheel_tags; std::istringstream wstream(wheel_content); std::string wline; while (std::getline(wstream, wline)) { while (!wline.empty() && (wline.back() == '\r' || wline.back() == '\n')) wline.pop_back(); if (wline.rfind("Tag:", 0) == 0) { std::string tag = wline.substr(4); size_t s = 0, e = tag.size(); while (s < e && (tag[s] == ' ' || tag[s] == '\t')) ++s; while (e > s && (tag[e - 1] == ' ' || tag[e - 1] == '\t')) --e; wheel_tags.push_back(tag.substr(s, e - s)); } } if (!wheel_tags.empty()) { bool compatible = false; const std::string abi_tag = PythonInterpreter::python_abi_tag(); for (const auto& tag : wheel_tags) { // Pure Python wheel: py3-none-any or cp312-none-any if (tag.find("-none-any") != std::string::npos) { compatible = true; break; } // Platform-specific: check ABI tag matches. if (tag.find(abi_tag) == 0) { // Accept if the platform tag matches the current OS. #ifdef _WIN32 if (tag.find("-win") != std::string::npos) compatible = true; #elif __APPLE__ if (tag.find("-macosx") != std::string::npos) compatible = true; #else if (tag.find("-linux") != std::string::npos || tag.find("-manylinux") != std::string::npos) compatible = true; #endif } } if (!compatible) { error = "Wheel is incompatible with this platform. Tags: "; for (size_t i = 0; i < wheel_tags.size(); ++i) { if (i > 0) error += ", "; error += wheel_tags[i]; } error += "; expected ABI: " + abi_tag; return false; } } } // Read RECORD (verify existence). const std::string record_path = dist_info_dir + "RECORD"; std::string record_content; if (!read_zip_text_file(reader.archive, record_path.c_str(), record_content, error)) return false; if (record_content.empty()) { error = "Wheel RECORD file is empty"; return false; } // Parse top_level.txt if present. std::string top_level; const std::string top_level_path = dist_info_dir + "top_level.txt"; std::string top_level_content; if (read_zip_text_file(reader.archive, top_level_path.c_str(), top_level_content, error)) { // top_level.txt contains one package name per line. std::istringstream tl_stream(top_level_content); std::string tl_line; std::vector top_levels; while (std::getline(tl_stream, tl_line)) { while (!tl_line.empty() && (tl_line.back() == '\r' || tl_line.back() == '\n')) tl_line.pop_back(); if (!tl_line.empty()) top_levels.push_back(tl_line); } if (top_levels.size() == 1) top_level = top_levels[0]; else if (top_levels.size() > 1) { // Ambiguous: multiple top-level packages. Fall through to Name-based fallback. } // Zero entries: leave top_level empty. } // If top_level.txt is not found, that's OK — it's optional per the wheel spec. // Determine the entry package in priority order. // 1. Cloud/catalog metadata — handled by caller, not here. // 2. Core Metadata Import-Name. // 3. top_level.txt if unambiguous. // 4. Normalized Name as fallback. if (!meta_import_name.empty()) { descriptor.entry_package = meta_import_name; } else if (!top_level.empty()) { descriptor.entry_package = top_level; } else { descriptor.entry_package = normalize_package_name(meta_name); } descriptor.dependencies = std::move(requires_dist); // Populate local identity fallbacks from wheel metadata. // Cloud metadata will override these when available. descriptor.name = sanitize_plugin_name(meta_name); descriptor.version = meta_version; descriptor.description = meta_summary; descriptor.author = meta_author; return true; } boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error) { namespace fs = boost::filesystem; if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { error = "Plugin directory does not exist: " + plugin_dir.string(); return {}; } fs::path py_entry; fs::path whl_entry; for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { if (is_ignored_plugin_directory(it->path())) continue; if (!fs::is_regular_file(it->status())) continue; const fs::path ext = it->path().extension(); if (ext == ".py") { if (!py_entry.empty()) { error = "Plugin directory contains multiple .py files: " + py_entry.filename().string() + " and " + it->path().filename().string(); return {}; } py_entry = it->path(); } else if (ext == ".whl") { if (!whl_entry.empty()) { error = "Plugin directory contains multiple .whl files: " + whl_entry.filename().string() + " and " + it->path().filename().string(); return {}; } whl_entry = it->path(); } } if (!py_entry.empty() && !whl_entry.empty()) { error = "Plugin directory contains both .py and .whl entry files"; return {}; } if (!py_entry.empty()) return py_entry; if (!whl_entry.empty()) return whl_entry; error = "Plugin directory does not contain a .py or .whl entry file"; return {}; } } // namespace Slic3r