diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index c8292496dc..31394fd7b2 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -1103,14 +1103,18 @@ endif() # front end is selected by source composition: PluginAuditManagerGui.cpp for the GUI app and # probe/PluginAuditManagerHeadless.cpp here. # +# Ownership: files under plugin/probe/ belong to the probe only; never link them into the GUI +# application or libslic3r_gui. Sources listed outside that directory are shared production code +# compiled unchanged by both. +# # Residual cost: the probe still links libslic3r (the large core library) for Utils, # LifecycleEvents, the model/print types used by the GUI-free host bindings and libslic3r's own # transitive dependencies. It does not link libslic3r_gui, wx or the GUI-only host/plugin sources. # Developer/security-analysis tool, so it is off by default. if (ORCA_BUILD_PLUGIN_PERMISSION_PROBE) add_executable(orca-plugin-probe - plugin/PluginPermissionProbe.cpp - plugin/PluginProbeCollector.hpp + plugin/probe/PluginPermissionProbe.cpp + plugin/probe/PluginProbeCollector.hpp plugin/probe/NanoSvgImplementation.cpp plugin/PluginAuditManager.cpp plugin/PluginAuditManagerInternal.hpp diff --git a/src/slic3r/plugin/PluginPermissionProbe.cpp b/src/slic3r/plugin/probe/PluginPermissionProbe.cpp similarity index 64% rename from src/slic3r/plugin/PluginPermissionProbe.cpp rename to src/slic3r/plugin/probe/PluginPermissionProbe.cpp index 6b47285189..59bb65c9df 100644 --- a/src/slic3r/plugin/PluginPermissionProbe.cpp +++ b/src/slic3r/plugin/probe/PluginPermissionProbe.cpp @@ -16,15 +16,19 @@ // The report describes only the paths this run exercised. CPython audit events are observations of // particular operations, not a notification for every Python call, so absence of a category in the // report is not evidence that the plugin cannot perform it. +// +// The report separates package validation, runtime compatibility, callback results and observed +// audit activity, then derives an advisory readiness status from them. Readiness never changes the +// process exit status, which stays load-based for existing callers. -#include "PluginAuditManager.hpp" -#include "PluginDescriptor.hpp" -#include "PluginFsUtils.hpp" -#include "PluginLoader.hpp" -#include "PluginManager.hpp" -#include "PluginProbeCollector.hpp" -#include "PythonInterpreter.hpp" -#include "PythonPluginInterface.hpp" +#include "slic3r/plugin/PluginAuditManager.hpp" +#include "slic3r/plugin/PluginDescriptor.hpp" +#include "slic3r/plugin/PluginFsUtils.hpp" +#include "slic3r/plugin/PluginLoader.hpp" +#include "slic3r/plugin/PluginManager.hpp" +#include "slic3r/plugin/PythonInterpreter.hpp" +#include "slic3r/plugin/PythonPluginInterface.hpp" +#include "slic3r/plugin/probe/PluginProbeCollector.hpp" #include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/Utils.hpp" @@ -39,6 +43,7 @@ #include #include +#include #include #include #include @@ -69,7 +74,8 @@ void print_usage(std::ostream& out) "Runs a plugin in a temporary data/working directory and reports the CPython audit\n" "events observed while its import, registration, on_load and discoverable callbacks\n" "execute. The probe does not prompt for permissions, write grants, install dependencies,\n" - "or start the OrcaSlicer GUI.\n" + "or start the OrcaSlicer GUI. The report summarizes package validation, runtime\n" + "compatibility, callback results and audit activity, plus an advisory readiness status.\n" "\n" " --json Emit the report as JSON (default: human-readable text)\n" " --help Show this message\n" @@ -208,6 +214,29 @@ void redirect_python_stdout() sys.attr("stdout") = sys.attr("stderr"); } +// Captured while the interpreter is still alive. Best effort: a failed lookup leaves the field +// empty and does not overwrite the initialization error. +std::string query_python_version() +{ + py::gil_scoped_acquire gil; + try { + return py::module_::import("platform").attr("python_version")().cast(); + } catch (const std::exception&) { + return {}; + } +} + +std::string package_format(const fs::path& path) +{ + std::string extension = path.extension().string(); + boost::algorithm::to_lower(extension); + if (extension == ".whl") + return "wheel"; + if (extension == ".py") + return "python-script"; + return extension.empty() ? "unknown" : extension; +} + template void invoke_callback(const std::shared_ptr& capability, const std::string& phase, @@ -330,24 +359,72 @@ nlohmann::json build_report(const Options& options, const PluginProbeCollector& collector, const std::vector& capabilities, const std::string& plugin_key, + const PluginDescriptor& descriptor, + bool descriptor_ready, + const std::string& descriptor_error, bool python_initialized, + const std::string& python_version, const std::string& python_error, bool loaded, const std::string& load_error, const std::string& data_dir) { - nlohmann::json observations = nlohmann::json::array(); - for (const AuditObservation& observation : collector.observations()) + const std::vector observed_events = collector.observations(); + nlohmann::json observations = nlohmann::json::array(); + std::size_t unattributed_events = 0; + std::size_t non_continued_events = 0; + for (const AuditObservation& observation : observed_events) { observations.push_back(observation_json(observation)); + if (observation.phase.empty()) + ++unattributed_events; + if (!observation.continued) + ++non_continued_events; + } - nlohmann::json callbacks = nlohmann::json::array(); - for (const ProbeCallbackRecord& callback : collector.callbacks()) + const std::vector callback_records = collector.callbacks(); + nlohmann::json callbacks = nlohmann::json::array(); + std::size_t invoked_callbacks = 0; + std::size_t failed_callbacks = 0; + std::size_t skipped_callbacks = 0; + for (const ProbeCallbackRecord& callback : callback_records) { callbacks.push_back(callback_json(callback)); + if (callback.status == "invoked") + ++invoked_callbacks; + else if (callback.status == "failed") + ++failed_callbacks; + else if (callback.status == "skipped") + ++skipped_callbacks; + } nlohmann::json capability_list = nlohmann::json::array(); for (const ProbeCapability& capability : capabilities) capability_list.push_back(capability_json(capability)); + const nlohmann::json summary = collector.summary(); + const bool package_valid = descriptor_ready && descriptor.metadata_valid; + const std::string entry_path = descriptor.entry_path.empty() ? options.plugin_path.string() : descriptor.entry_path; + const std::string observed_plugin = plugin_key.empty() ? make_local_plugin_key(options.plugin_path.stem().string()) : plugin_key; + + nlohmann::json readiness_issues = nlohmann::json::array(); + if (!descriptor_ready) { + readiness_issues.push_back({{"severity", "blocking"}, {"detail", "package validation failed: " + descriptor_error}}); + } else if (!python_initialized) { + readiness_issues.push_back( + {{"severity", "blocking"}, + {"detail", "Python interpreter initialization failed" + (python_error.empty() ? std::string{} : ": " + python_error)}}); + } else if (!loaded) { + readiness_issues.push_back({{"severity", "blocking"}, + {"detail", "plugin_loader::load failed" + (load_error.empty() ? std::string{} : ": " + load_error)}}); + } + if (package_valid && python_initialized && loaded && failed_callbacks > 0) + readiness_issues.push_back( + {{"severity", "warning"}, + {"detail", std::to_string(failed_callbacks) + " callback(s) failed; see callbacks for the individual records"}}); + + const char* readiness_status = (!package_valid || !python_initialized || !loaded) ? "not_ready" : + failed_callbacks > 0 ? "degraded" : + "ready"; + return { {"tool", "orca-plugin-permission-probe"}, {"mode", "record-only"}, @@ -355,7 +432,50 @@ nlohmann::json build_report(const Options& options, {"plugin", { {"path", options.plugin_path.string()}, - {"key", plugin_key}, + {"key", observed_plugin}, + }}, + {"readiness", + { + {"status", readiness_status}, + {"advisory", true}, + {"checks", + { + {"package_valid", package_valid}, + {"python_initialized", python_initialized}, + {"load_ok", loaded}, + {"failed_callback_count", failed_callbacks}, + }}, + {"issues", readiness_issues}, + {"exit_status", + { + {"code", loaded ? 0 : 1}, + {"semantics", "0 when the plugin loaded, 1 on package validation, initialization or load failure, 2 on a usage error"}, + {"driven_by", "load"}, + {"note", "Readiness is advisory and does not change the exit status; degraded still exits 0."}, + }}, + }}, + {"package", + { + {"valid", package_valid}, + {"metadata_read", descriptor_ready}, + {"entry_path", entry_path}, + {"entry_format", package_format(fs::path(entry_path))}, + {"metadata", + { + {"name", descriptor.name}, + {"version", descriptor.version}, + {"author", descriptor.author}, + {"description", descriptor.description}, + {"entry_package", descriptor.entry_package}, + }}, + {"declared_dependencies", descriptor.dependencies}, + {"error", descriptor_ready ? std::string{} : descriptor_error}, + {"installation", + { + {"performed", false}, + {"dependencies_installed", false}, + {"note", "The probe inspects the given package in place; it does not install the package or its dependencies."}, + }}, }}, {"environment", { @@ -367,10 +487,66 @@ nlohmann::json build_report(const Options& options, {"interactive_prompts", false}, {"grants_persisted", false}, }}, + {"runtime", + { + {"python", + { + {"initialized", python_initialized}, + {"version", python_version}, + }}, + {"headless", true}, + {"gui_initialized", false}, + {"unavailable_gui_domains", nlohmann::json::array({"orca.host.app", "orca.host.presets", "orca.host.ui", "orca.host.plugin"})}, + {"gui_domain_behavior", + "Attribute access raises an unsupported error; the probe records it as a failed callback when plugin code " + "touches the domain."}, + {"config_api", + { + {"get_config", "empty object"}, + {"get_config_version", "empty string"}, + {"save_config", "refused"}, + {"persisted", false}, + }}, + {"storage_api", + { + {"available", false}, + {"detail", "orca.host.plugin.storage() raises an unsupported error because the probe does not start the " + "PluginManager registry."}, + }}, + }}, {"load", {{"ok", loaded}, {"error", load_error}, {"capabilities", capability_list}}}, + {"callback_results", + { + {"records_section", "callbacks"}, + {"total", callback_records.size()}, + {"invoked", invoked_callbacks}, + {"failed", failed_callbacks}, + {"skipped", skipped_callbacks}, + {"note", "The callbacks array holds the individual records; this section summarizes them and feeds readiness."}, + }}, {"callbacks", callbacks}, + {"audit_activity", + { + {"records_section", "observations"}, + {"summary_section", "summary"}, + {"observed_event_count", observed_events.size()}, + {"category_count", summary["categories"].size()}, + {"unattributed_event_count", unattributed_events}, + {"non_continued_event_count", non_continued_events}, + {"side_effects", + { + {"plugin_code_executed", true}, + {"installation_performed", false}, + {"dependencies_installed", false}, + {"grants_persisted", false}, + {"filesystem_network_process_contained", false}, + {"temporary_data_dir_removed_on_exit", true}, + }}, + {"note", "Side effects are the probe's own actions plus what the audit hook observed; only exercised paths are " + "represented."}, + }}, {"observations", observations}, - {"summary", collector.summary()}, + {"summary", summary}, {"coverage", { {"exhaustive", false}, @@ -406,9 +582,14 @@ nlohmann::json build_report(const Options& options, void print_text_report(const nlohmann::json& report) { - std::cout << "OrcaSlicer plugin permission probe (record-only)\n" + const auto or_none = [](const std::string& value) { return value.empty() ? std::string("(none)") : value; }; + + std::cout << "OrcaSlicer plugin readiness report (record-only)\n" "WARNING: observational only; not exhaustive and not a sandbox.\n\n"; std::cout << "Plugin: " << report["plugin"]["path"].get() << "\n"; + std::cout << "Readiness: " << report["readiness"]["status"].get() << " (advisory; exit status follows the load result)\n"; + for (const auto& issue : report["readiness"]["issues"]) + std::cout << " " << issue["severity"].get() << ": " << issue["detail"].get() << "\n"; std::cout << "Python initialized: " << (report["environment"]["python_initialized"].get() ? "yes" : "no") << "\n"; if (!report["environment"]["python_error"].get().empty()) std::cout << "Python error: " << report["environment"]["python_error"].get() << "\n"; @@ -416,12 +597,56 @@ void print_text_report(const nlohmann::json& report) if (!report["load"]["error"].get().empty()) std::cout << "Load error: " << report["load"]["error"].get() << "\n"; + const auto& package = report["package"]; + std::cout << "\nPackage: valid=" << (package["valid"].get() ? "yes" : "no") + << " format=" << package["entry_format"].get() << "\n"; + std::cout << " entry: " << package["entry_path"].get() << "\n"; + std::cout << " name=" << or_none(package["metadata"]["name"].get()) + << " version=" << or_none(package["metadata"]["version"].get()) + << " author=" << or_none(package["metadata"]["author"].get()) << "\n"; + if (!package["metadata"]["entry_package"].get().empty()) + std::cout << " entry package: " << package["metadata"]["entry_package"].get() << "\n"; + std::cout << " declared dependencies:"; + if (package["declared_dependencies"].empty()) { + std::cout << " (none)"; + } else { + bool first = true; + for (const auto& dependency : package["declared_dependencies"]) { + if (!first) + std::cout << ", "; + std::cout << dependency.get(); + first = false; + } + } + std::cout << "\n installed: no; the probe does not install the package or its dependencies\n"; + if (!package["error"].get().empty()) + std::cout << " package error: " << package["error"].get() << "\n"; + + const auto& runtime = report["runtime"]; + std::cout << "\nRuntime: python_version=" << or_none(runtime["python"]["version"].get()) + << " headless=yes gui=not_started\n"; + std::cout << " unavailable GUI domains:"; + { + bool first = true; + for (const auto& domain : runtime["unavailable_gui_domains"]) { + if (!first) + std::cout << ", "; + std::cout << domain.get(); + first = false; + } + } + std::cout << "\n config API: returns an empty object, saves are refused, nothing persisted\n"; + std::cout << " storage API: unavailable (orca.host.plugin.storage raises)\n"; + std::cout << "\nCapabilities (" << report["load"]["capabilities"].size() << "):\n"; for (const auto& capability : report["load"]["capabilities"]) std::cout << " " << capability["name"].get() << " [" << capability["type"].get() << "] enabled=" << (capability["enabled"].get() ? "yes" : "no") << "\n"; - std::cout << "\nCallbacks (" << report["callbacks"].size() << "):\n"; + const auto& callback_results = report["callback_results"]; + std::cout << "\nCallbacks (" << callback_results["total"].get() << ": " << callback_results["invoked"].get() + << " invoked, " << callback_results["failed"].get() << " failed, " + << callback_results["skipped"].get() << " skipped)\n"; for (const auto& callback : report["callbacks"]) { std::cout << " " << callback["status"].get() << " " << callback["callback"].get(); if (!callback["capability"].get().empty()) @@ -431,7 +656,8 @@ void print_text_report(const nlohmann::json& report) std::cout << "\n"; } - std::cout << "\nObserved audit events (" << report["observations"].size() << "):\n"; + std::cout << "\nObserved audit events (" << report["audit_activity"]["observed_event_count"].get() << " events, " + << report["audit_activity"]["category_count"].get() << " categories):\n"; for (const auto& observation : report["observations"]) { std::cout << " phase=" << observation["phase"].get() << " event=" << observation["event"].get() << " category=" << observation["category"].get(); @@ -470,6 +696,10 @@ void print_text_report(const nlohmann::json& report) if (report["summary"]["categories"].empty()) std::cout << " (none observed)\n"; + std::cout << "\nObserved side effects: plugin code executed; package and dependencies not installed; no grants persisted; " + "filesystem, network and process access not contained\n"; + std::cout << "Exit status: " << report["readiness"]["exit_status"]["code"].get() << " (driven by load; readiness is advisory)\n"; + std::cout << "\nCoverage limits:\n"; for (const auto& note : report["coverage"]["notes"]) std::cout << " - " << note.get() << "\n"; @@ -517,6 +747,10 @@ int main(int argc, char** argv) } } + std::string python_version; + if (python_initialized) + python_version = query_python_version(); + bool loaded = false; std::string load_error = descriptor_error; Plugin plugin; @@ -554,7 +788,8 @@ int main(int argc, char** argv) const nlohmann::json report = build_report(options, collector, capabilities, descriptor.plugin_key.empty() ? make_local_plugin_key(options.plugin_path.stem().string()) : descriptor.plugin_key, - python_initialized, python_error, loaded, load_error, workspace.root().string()); + descriptor, descriptor_ready, descriptor_error, python_initialized, python_version, + python_error, loaded, load_error, workspace.root().string()); if (options.json) std::cout << report.dump(2, ' ', false, nlohmann::json::error_handler_t::replace) << "\n"; diff --git a/src/slic3r/plugin/PluginProbeCollector.hpp b/src/slic3r/plugin/probe/PluginProbeCollector.hpp similarity index 98% rename from src/slic3r/plugin/PluginProbeCollector.hpp rename to src/slic3r/plugin/probe/PluginProbeCollector.hpp index 626cce3b20..6f5f2707de 100644 --- a/src/slic3r/plugin/PluginProbeCollector.hpp +++ b/src/slic3r/plugin/probe/PluginProbeCollector.hpp @@ -6,7 +6,7 @@ // audited operation may proceed. Keeping it independent of wx/Python also makes its grouping and // phase attribution testable without starting the interpreter. -#include "PluginAuditManager.hpp" +#include "slic3r/plugin/PluginAuditManager.hpp" #include diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index fdf99ff1fc..c9bd737429 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -9,7 +9,7 @@ #include #include #include -#include +#include #include #include #include